hands-on labon a real NVIDIA GPU

Inference Serving Patterns: Dynamic Batching, Throughput, and the Triton Mental Model

pbtxt.

time
40 min
checked steps
4
level
Intermediate
setup
None
NCP-GENLNCP-AIONCA-GENLPart of the AI Engineer Course

$29.99a month with Pro

Pro opens this lab and every other one. Cancel any time.

Lab cockpit40 min · 4 stepsSession running
3 / 4 steps passingProduction bridge: the Triton config.pbtxt equivalent · step 4 of 4
lab.ipynb

Step 4 — Production bridge: the Triton `config.pbtxt` equivalent

Everything we just built — model loading, dynamic batching, request queue — is what nvcr.io/nvidia/tritonserver does in production, in a battle-tested C++ binary with proper gRPC/HTTP, Prometheus metrics, model versioning, and multiple ba.

[ ]:
from pathlib import Pathfrom IPython.display import Code, display config_path = Path('/home/labuser/workspace/triton-model-config.pbtxt')model_config_pbtxt = config_path.read_text() print(f"Triton config: {config_path}  ({len(model_config_pbtxt)} bytes)")display(Code(filename=str(config_path), language='protobuf'))
[ ]:
# Mapping table: our code <-> Triton configmapping = [    ('DynamicBatcher.max_batch_size',     'max_batch_size: 32'),    ('DynamicBatcher.max_queue_delay_ms', 'dynamic_batching { max_queue_delay_microseconds: 10000 }'),    ('Our single-model setup',            'instance_group { count: 1, kind: KIND_GPU }'),    ('Input tensor shape (1,3,224,224)',  'input { dims: [ 3, 224, 224 ] } + max_batch_size'),    ('model(x) forward pass',             'platform: pytorch_libtorch (runs TorchScript)'),    ('Future.done.set()',                 'Triton responds over HTTP/gRPC'),    ('No equivalent in our mini-server',  'version_policy — hot-swap model versions, A/B test'),    ('No equivalent in our mini-server',  'preferred_batch_size list — warm caches for common sizes'),] print(f'{"Our mini-server":<40s} {"Triton config.pbtxt":<50s}')print('-' * 90)for ours, triton in mapping:    print(f'{ours:<40s} {triton:<50s}')
[ ]:
lab.check(4)
step 4 of 4, replayed: Production bridge: the Triton config.pbtxt equivalent. The code, the output and the check are the lab's own.

4 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

    The naive baseline: one request at a time

    read this step below ↓

  2. 2

    Dynamic batching: the one technique that 3-10x's throughput

  3. 3

    The throughput-latency tradeoff: sweep max_batch_size

  4. 4

    Production bridge: the Triton config.pbtxt equivalent

Step 1 as it appears in the lab

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

Step 1: The naive baseline: one request at a time

You have a model. Now a thousand users want to query it simultaneously. Your first instinct — wrap it in Flask/FastAPI, one request at a time — wastes 90% of your GPU. A modern inference server like NVIDIA Triton Inference Server turns a bare model into a production-grade endpoint with dynamic batching, multi-model sharing, versioning, and built-in Prometheus metrics.

This lab teaches the mental model of inference serving by building a mini-Triton in Python. Why build it instead of just running Triton? Because once you've implemented dynamic batching in 30 lines, you understand why Triton's config.pbtxt has the knobs it has — and you can debug your production Triton deployment when it misbehaves.

The core insight: GPUs are batch-mode accelerators

A GPU doing matrix multiplication on batch=1 runs at maybe 5% of its peak FLOPS. The tensor cores need work — large matrices — to be saturated. Serving requests one at a time is like hiring a 1,000-person factory and giving them one order every minute.

Dynamic batching is the fix: incoming requests wait in a queue for up to N milliseconds OR until the queue has B requests, whichever comes first. Then the server fires a single large batched forward pass. Each individual request waits a bit longer, but aggregate throughput goes 5-50×.

References


A ResNet-style convnet classifying 224×224 images. We'll simulate concurrent client load and measure QPS (queries per second) and latency percentiles.

Expected result on an RTX 3060 Ti: ~20-40 QPS, which is terrible — the GPU sits mostly idle between requests. The p99 latency will be close to the mean because there's no queue contention, but throughput is severely capped.

import torch, torch.nn as nn
import time, queue, threading

device = torch.device('cuda')
torch.manual_seed(0)

# A realistic-size image model (~12M params)
model = nn.Sequential(
    nn.Conv2d(3, 64, 7, stride=2, padding=3), nn.ReLU(),
    nn.Conv2d(64, 128, 3, stride=2, padding=1), nn.ReLU(),
    nn.Conv2d(128, 256, 3, stride=2, padding=1), nn.ReLU(),
    nn.Conv2d(256, 512, 3, stride=2, padding=1), nn.ReLU(),
    nn.AdaptiveAvgPool2d(1), nn.Flatten(),
    nn.Linear(512, 1000),
).to(device).half().eval()

# Warmup
with torch.no_grad():
    for _ in range(3):
        _ = model(torch.randn(1, 3, 224, 224, device=device, dtype=torch.float16))
    torch.cuda.synchronize()

@torch.no_grad()
def infer_naive(x):
    """One request, one forward pass. Classic REST-endpoint pattern."""
    return model(x)

print('Model ready. ~12M params, fp16.')

The load-test harness

A thread-per-client test rig that submits n_requests through handler_fn using concurrency parallel threads. Measures QPS and latency percentiles — the two numbers every inference SLO is written against. torch.cuda.synchronize() after each call makes the timer measure actual GPU execution, not just CUDA API submission.

# Simulate N concurrent clients each firing requests
def run_load_test(handler_fn, n_requests=200, concurrency=16):
    """Fires n_requests through handler_fn using `concurrency` threads.
    Returns QPS + latency percentiles."""
    req_queue = queue.Queue()
    for i in range(n_requests):
        req_queue.put(i)
    latencies = []
    lock = threading.Lock()

    def client():
        while True:
            try:
                req_queue.get_nowait()
            except queue.Empty:
                return
            x = torch.randn(1, 3, 224, 224, device=device, dtype=torch.float16)
            t0 = time.perf_counter()
            _ = handler_fn(x)
            torch.cuda.synchronize()
            with lock:
                latencies.append((time.perf_counter() - t0) * 1000)

    threads = [threading.Thread(target=client) for _ in range(concurrency)]
    start = time.perf_counter()
    for t in threads: t.start()
    for t in threads: t.join()
    elapsed = time.perf_counter() - start

    qps = n_requests / elapsed
    latencies.sort()
    p50 = latencies[len(latencies) // 2]
    p99 = latencies[int(len(latencies) * 0.99)]
    return qps, p50, p99

Baseline — naive one-at-a-time serving

Fire 200 requests through the naive handler at concurrency=16. Each request pays the full kernel-launch + forward-pass cost on its own. You'll see QPS bottlenecked well below the GPU's throughput capacity — and p99 latency dominated by CUDA stream contention.

print('Running naive-serving load test (200 requests, 16 clients)...')
naive_qps, naive_p50_ms, naive_p99_ms = run_load_test(infer_naive)
print(f'  QPS : {naive_qps:.1f}')
print(f'  p50 : {naive_p50_ms:.1f} ms')
print(f'  p99 : {naive_p99_ms:.1f} ms')

what you get

  • A JupyterLab notebook on a real NVIDIA GPU
  • A check at the end of every step
  • Hints and the full solution
  • Nothing to install or configure

before you start

  • PyTorch tensors and model forward pass
  • Python threading / queue basics
  • Concept of QPS, p50, p99 latency

Exam domains covered

Model Deployment & Inference OptimizationGPU Acceleration & Distributed Training

Part of the LLMOps and MLOps track

Lab 2 of 22. vLLM serving, load tests against SLOs, tracing, drift monitoring and prompt tests in CI.

Questions about this lab

How is this different from the vLLM lab?

This lab teaches the general serving pattern, dynamic (request-level) batching with a queue, a delay knob, and a batch-size ceiling, that works for any model served by Triton, TorchServe, or your own harness. vLLM implements a different pattern called continuous (iteration-level) batching, which only makes sense for autoregressive LLM decoding where each step produces one token per sequence. Take both: this one for convnets / classifiers / general inference, vLLM for LLM-specific serving.

Why does max_queue_delay_microseconds matter so much?

Because it sets a floor on the tail latency any single request can see. If you configure 10 ms of queue delay and a request arrives alone, it waits 10 ms for no benefit, and that latency is baked into your p99. Setting it too low starves the batcher and you're back to naive serving; setting it too high lets tail requests exceed your SLA. The Step 3 sweep shows you the curve; the Step 4 reflection forces you to pick a specific value that stays inside a 150 ms p99 SLA with room to spare.

What's the difference between dynamic batching and continuous batching?

Dynamic (request-level) batching, which this lab implements, groups N separate incoming requests into one forward pass and returns N results, the whole batch starts and ends together. Continuous batching (what vLLM does) operates at the token-generation step: at every decode step it re-packs the active set of sequences, adds new ones that arrived mid-generation, and evicts ones that finished. You need continuous batching for LLM decoding because responses have wildly different lengths; you don't need it for a classifier that outputs one tensor per request.

What does a real Triton config.pbtxt add on top of my DynamicBatcher?

Production pieces you didn't implement: version_policy to control which model version is live, preferred_batch_size with a list of sizes to warmup cuDNN autotuner on, instance_group to run multiple model replicas per GPU, metrics export for Prometheus, gRPC and HTTP endpoints, model warmup sequences, ensemble pipelines, and explicit GPU selection. The DynamicBatcher teaches the core loop; Triton adds everything around it. Step 4 specifically calls out the pieces the mini-server doesn't have.

How do I pick max_batch_size from the sweep data?

Plot QPS and p99 on a dual axis against max_batch_size. Walk up the curve until p99 brushes your SLA headroom, subtract the network/queue budget from your user-facing SLA, and that's your wall. The highest batch size that stays under the wall is your answer, not the one with the globally highest QPS. If the curve peaks early, you're compute-bound at that batch size and you need a smaller model or more GPUs, not a bigger batch.

What does each step's grader check?

Step 1 verifies naive_qps > 0, 0 < naive_p50_ms < 10_000, and naive_p99_ms >= naive_p50_ms. Step 2 requires batched_qps > naive_qps * 1.05, proving the batcher thread is actually firing. Step 3 validates sweep_results has ≥3 entries with the required keys and that the best QPS exceeds the smallest-batch run by ≥1.1×, catching broken sweeps. Step 4 enforces that model_config_pbtxt contains name:, platform:, max_batch_size:, input, output, and dynamic_batching, and that the declared platform is one of Triton's valid backends.

What you'll build in this inference-serving lab

Dynamic batching is the single technique that 3-10×'s inference throughput, and most engineers using Triton or TorchServe have never actually built one. This lab fixes that: you'll write a working mini-Triton server in ~30 lines of Python, a DynamicBatcher class with a background thread, an in-memory queue, max_batch_size and max_queue_delay_ms knobs, per-request Futures, then load-test it, sweep the tradeoff curve, and translate the whole thing into a real Triton config.pbtxt. You'll walk away with a mental model of the throughput-vs-p99 curve that transfers directly to Triton, TorchServe, vLLM, or any serving stack, plus the judgment to pick max_batch_size and max_queue_delay_microseconds against an actual SLA instead of maxing QPS in isolation. About 40 minutes on a live NVIDIA GPU pod, PyTorch, futures, and the load-test harness are ready.

The substance is the knobs and what they cost. max_batch_size sets the ceiling; pushing it higher lifts QPS until larger batches' latency blows past your SLA, that's why one number isn't the answer. max_queue_delay_microseconds is the maximum extra wait you'll inflict on an early arrival in exchange for catching more requests in the same batch, and it sets a floor on the tail latency any single request can see: configure 10 ms of queue delay and a request arriving alone still waits 10 ms for no benefit. Set it too low, the batcher starves and you're back to naive serving; set it too high, tail requests exceed SLA. The production rule: subtract your network/queue budget from the user-facing SLA, and the highest batch size that stays under that wall is the answer, not the point with globally highest QPS.

The mental-model distinction engineers get wrong: dynamic (request-level) batching groups N separate incoming requests into one forward pass and returns N results, whole batch starts and ends together. Continuous (iteration-level) batching, which vLLM implements, operates at the token-generation step: at every decode step it re-packs the active sequence set, adds new ones that arrived mid-generation, evicts finished ones. You need continuous batching for LLM decoding because responses have wildly different lengths; you don't need it for a classifier that outputs one tensor per request. This lab covers the first pattern; the vLLM lab covers the second. Your mini-server maps almost 1:1 to Triton, max_batch_size → max_batch_size, max_queue_delay_ms → max_queue_delay_microseconds, thread-count → instance_group.count, which is why writing the bare-metal version once makes every production serving config readable.

Prereqs: PyTorch forward-pass basics, Python threading + queue.Queue familiarity, a grasp of QPS/p50/p99. Preinstalled: PyTorch, load-test harness, JupyterLab. Grading checks real measurements: naive baseline has positive QPS with p99 ≥ p50, batched QPS beats naive by at least 1.05× (proving the batcher thread is firing), sweep_results has ≥3 points with a best QPS at least 1.1× the smallest batch (proving a real sweet spot exists), and model_config_pbtxt contains every Triton-required field plus a valid platform (pytorch_libtorch, onnxruntime_onnx, tensorrt_plan, python, or vllm).