ml-models / Inference and deployment / Model serving core

Model serving

A trained model is a file of weights and a Python class. Serving it means answering requests from other programs, fast and cheaply, for months. This page covers how a PyTorch model is saved and loaded safely, the difference between running it eagerly, compiled and exported, how dynamic and continuous batching trade a little latency for a lot of throughput, when a CPU beats a GPU, and a checklist for putting a model behind an endpoint.

On this page
  1. Problem
  2. Intuition
  3. Mechanics
  4. Weights: saving and loading safely
  5. Eager, compiled, exported
  6. Dynamic shapes in export
  7. Inference mode and eval
  8. Dynamic batching
  9. Continuous batching for generation
  10. CPU versus GPU
  11. A serving checklist
  12. Worked example
  13. Four batching policies on one arrival trace
  14. CPU against GPU on the same cost model
  15. Static against continuous batching
  16. Implementation
  17. Tradeoffs
  18. Connections
  19. Read first
  20. Read next
  21. References

Problem

Training ends with a state_dict and the code that built the model. A serving system has different goals from a training loop:

  • Latency. A user waits for each answer. What matters is not the mean but the tail: the 90th or 99th percentile request.
  • Throughput and cost. Hardware is billed by the hour, so the number of requests answered per second per device decides what serving costs.
  • Portability. The serving process may not be the training environment: a different machine, a C++ binary, a phone. The model has to survive leaving its Python source behind.
  • Safety and stability. Loading a file must not run arbitrary code, and the same input must give the same output today and next month.

The tension is between the first two. Sixteen requests arrive over 22 ms at a model that takes 5 ms of fixed overhead plus 1 ms per item. Run them one at a time and the queue grows: the mean request waits 40.25 ms and the last finishes at 96 ms. Group them into batches and the same hardware averages 15.31 ms. Batching is the main lever in serving, and choosing how to batch is most of the design.

Intuition

There are two things to ship: the weights (tensors) and the program (what to compute with them). PyTorch offers three ways to carry the program:

  • Eager. Ship the Python class, load the weights into it, call it. Every operation is dispatched one at a time from Python. Simple, flexible, and the slowest per call.
  • Compiled. Still Python, but torch.compile captures the model into graphs at run time and generates fused kernels for them (see GPU execution). Faster, but the first calls pay for compilation and new input shapes can recompile.
  • Exported. torch.export traces the model ahead of time into a single graph of ATen operators with no Python control flow left in it. The result is a file that can be loaded without the model's source, and AOTInductor can compile it into a shared library that runs without Python at all.
Three ways to carry a model from training to serving A trained nn.Module with its state_dict feeds three paths. Eager: the Python class plus torch.load of the weights, run operator by operator in the Python process. Compiled: torch.compile wraps the module and captures graphs on the first calls, then runs generated kernels, still inside Python, recompiling on new shapes. Exported: torch.export traces once ahead of time into an ExportedProgram of ATen operators, saved as a .pt2 file; it can be loaded back with torch.export.load, or compiled by AOTInductor into a package that runs from C++ without Python. nn.Module + state_dict eager model class + torch.load weights_only=True Python process: one dispatch per op flexible, debuggable, most overhead compiled torch.compile(model) capture on first call Python process: fused generated kernels warm-up cost, guards, recompiles on new shapes exported torch.export(model, args) ahead of time, one graph ExportedProgram ATen ops, .pt2 torch.export.load AOTInductor: no Python

The second idea is batching. A GPU computing one row costs nearly the same as computing a dozen, because most of the time goes to fixed overhead: launching kernels and reading the weights from memory. So the server holds requests briefly and runs them together. A dynamic batcher collects requests until the batch is full or the oldest has waited long enough. For text generation, where a request is many decode steps long and requests finish at different times, a continuous batcher lets a new request take a finished one's place at the next step instead of waiting for the whole batch to finish.

Mechanics

Weights: saving and loading safely

torch.save(obj, f) writes a zip archive: tensor storages as raw byte files, and a pickle that describes how to rebuild obj around them. The usual thing to save is model.state_dict(), a dict from parameter names to tensors, rather than the model object, so loading doesn't depend on the class being importable under the same name.

Pickle is a small program: unpickling can call any importable function, so loading an untrusted pickle can run arbitrary code. torch.load therefore defaults to weights_only=True (when no custom pickle_module is passed), which uses a restricted unpickler that only rebuilds tensors, primitive types and an allowlist of safe classes. Two other arguments matter for serving: map_location moves storages to a different device as they load (a GPU checkpoint onto a CPU server), and mmap=True maps the file into memory instead of reading it, so a large checkpoint can be loaded without holding two copies.

Eager, compiled, exported

In eager mode, Python runs forward line by line and each operator goes through the dispatcher separately. For a large model on a big batch, the kernels dominate and the overhead is noise; for a small model or batch size 1, the Python and launch overhead can be most of the time.

torch.compile(model) returns a wrapper. On the first call TorchDynamo reads the Python bytecode, captures the tensor operations into a graph (breaking it where it meets something it can't trace), records guards (the input shapes, dtypes and Python values the graph assumed), and hands the graph to a backend, by default Inductor, which generates fused kernels. Later calls check the guards and reuse the compiled code; a failed guard triggers a recompile, and after torch._dynamo.config.recompile_limit recompiles (8 by default) that frame falls back to eager. mode="reduce-overhead" adds CUDA graphs, which replay a recorded sequence of kernel launches with one call, aimed squarely at small-batch serving. dynamic=True asks for shape-generic code up front instead of specializing and recompiling.

torch.export.export(mod, args) instead traces once, ahead of time, and requires the whole model to become one graph: no graph breaks. Data-dependent Python control flow is not allowed (use torch.cond for branches that must stay in the graph). By default (strict=False) it traces by running the Python code with fake tensors; strict=True traces through TorchDynamo instead. The result, an ExportedProgram, holds the graph in the functional ATen operator set, the parameters and buffers, a signature saying which graph inputs are parameters and which are user inputs, and range constraints on symbolic dimensions.

Dynamic shapes in export

By default export specializes on the example input's shapes: export with a batch of 4 and the program accepts only batches of 4. A server needs variable batch size and often variable sequence length, declared with dynamic_shapes: a Dim("batch", min=1, max=64) names a symbolic size with a range, and the hints Dim.AUTO, Dim.DYNAMIC and Dim.STATIC let export infer or fix a dimension. Export then checks the model's code against the declared ranges, and the loaded module checks inputs against them at call time (ExportedProgram.module(check_guards=True)). torch.export.save writes the program to a .pt2 archive; its docstring warns that the format is under active development and saved files may not load in newer PyTorch releases, and torch.export.load uses pickle, so the same rule applies: never load one from an untrusted source.

Inference mode and eval

Two switches belong around every serving call. model.eval() (which is model.train(False)) changes what layers compute: dropout becomes the identity and batch norm uses its running statistics instead of the batch's. torch.inference_mode() changes what autograd records: like no_grad it builds no graph, and it also skips view tracking and version-counter bumps, so tensors cost a little less. Tensors created inside can't be used in autograd later. Forgetting eval() is a correctness bug (outputs change with the batch); forgetting inference mode only wastes memory and time.

ComponentShape/ParamsMeaningSource
torch.saveobj, f, pickle_protocolZip archive: tensor storages plus a pickle describing the objectserialization.py
torch.loadmap_location, weights_only, mmapRestricted unpickler by default; move storages to another device; memory-map large filesserialization.py
_default_to_weights_onlypickle_module is None → TrueWhy weights_only=None means safe loadingserialization.py
torch.compilefullgraph=False, dynamic=None, backend="inductor", modeJust-in-time graph capture and kernel generation, guarded, recompiles on new shapes__init__.py
torch.export.exportmod, args, kwargs, dynamic_shapes, strict=FalseAhead-of-time trace to one graph of functional ATen opsexport/__init__.py
DimDim(name, min=, max=), AUTO/DYNAMIC/STATICA symbolic dimension and its allowed rangedynamic_shapes.py
ExportedProgram.modulecheck_guards=TrueA callable GraphModule that checks inputs against the shape constraintsexported_program.py
torch.export.save / load.pt2 archiveSerialize an ExportedProgram; load uses picklesave, load
aoti_compile_and_packageexported_program, package_pathAOTInductor: compile an exported program into a package loadable without Python_inductor/__init__.py
aoti_load_packagepath, run_single_threaded, device_index, *, num_runners, use_stream_affinityLoad that package back as a callable_inductor/__init__.py
inference_modecontext manager, thread-localNo autograd graph, no view tracking or version countersgrad_mode.py
Module.evaltrain(False)Dropout off, batch norm on running statisticsmodule.py

Dynamic batching

Model one batch's cost as a fixed part plus a per-item part:

latency(B) = cfixed + B · citem, throughput(B) = B / latency(B)

Throughput rises toward 1/citem as B grows, and the larger cfixed is compared with citem, the bigger the batch needed to get there. A dynamic batcher has two knobs: the largest batch it will form, and the longest it will make the oldest queued request wait. When the device frees up it starts a batch as soon as either the queue holds a full batch or the oldest request's deadline passes, whichever comes first, and takes everything that has arrived by then up to the maximum. Clipper (Crankshaw et al., 2017) built a serving system around this idea, tuning the maximum batch size adaptively per model to meet a latency target.

Waiting is a bet. At light load, a short wait collects requests that would otherwise run in near-empty batches. At heavy load the queue fills batches on its own, and any deliberate wait only adds latency. The worked example shows the second case.

Continuous batching for generation

An autoregressive model produces one token per request per forward pass, and requests need different numbers of tokens. With static batching, the batch runs until its longest request finishes; slots whose requests finished early sit idle, and new requests wait for the whole batch. Orca (Yu et al., OSDI 2022) introduced iteration-level scheduling: the scheduler revisits the batch after every decode step, removes finished requests and admits waiting ones into the free slots. This is now called continuous batching. It needs a KV cache that can grow and shrink per request; vLLM's PagedAttention (Kwon et al., SOSP 2023) stores the cache in fixed-size blocks for exactly this reason, so a finished request's memory goes straight back to the pool.

Static and continuous batching of the same eight decode requests Each column is one decode step and each row one of 4 batch slots; a filled cell is a slot generating a token, an empty cell is idle. Static batching takes 15 steps: after the first batch's short requests finish, steps 4 to 8 run only the longest request with three idle slots, and the second batch starts only at step 9. Continuous batching admits a waiting request into each slot as soon as it frees, takes 10 steps and keeps 85% of slot-steps busy, against 57% for static. static: 15 steps, 57% busy r0 r1 r2 r3 r0 r1 r2 r3 r0 r1 r2 r1 r2 r1 r1 r1 r1 r1 r4 r5 r6 r7 r4 r5 r6 r7 r4 r5 r6 r4 r6 r4 r6 r6 slot 0 slot 1 slot 2 slot 3 continuous: 10 steps, 85% busy r0 r1 r2 r3 r0 r1 r2 r3 r0 r1 r2 r4 r1 r2 r4 r5 r1 r4 r5 r6 r1 r4 r5 r6 r1 r4 r6 r7 r1 r6 r7 r1 r6 r6 slot 0 slot 1 slot 2 slot 3 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14

Requests are numbered r0 to r7; each row lists the requests running at that step, packed into the first slots for readability. Real schedulers keep a request in the slot it was admitted to.

CPU versus GPU

The same cost model explains when a CPU is the right server. A CPU has small fixed overhead per call and a large per-item cost; a GPU has a larger fixed cost (kernel launches, a host-to-device copy) and a tiny per-item cost. At batch size 1 the CPU can win outright; the GPU's advantage appears only once batches are large enough to amortize its fixed cost, which in turn needs enough traffic to fill those batches. Small models (linear models, gradient-boosted trees, small MLPs) at modest traffic often serve best on CPU, where instances are cheap and plentiful. Large networks, and anything generating tokens, belong on accelerators, where the batching machinery above is what makes them affordable. Quantization shrinks the per-item cost on both.

A serving checklist

  • Artifact. Save a state_dict or an exported program, record the PyTorch version and the preprocessing that produced the inputs, and load with weights_only=True.
  • Mode. model.eval() once at load; torch.inference_mode() around every call.
  • Shapes. Decide which dimensions vary and declare them (Dim in export, dynamic=True or bucketed sizes for compile). Warm up every shape bucket before taking traffic so no user pays for compilation.
  • Parity. Compare served outputs with the eager model on a fixed set of inputs, within a tolerance that allows for kernel and precision differences.
  • Batching. Choose a maximum batch from the latency budget and the cost curve; add a wait only if traffic is light enough that batches would otherwise be small.
  • Measure the tail. Track p50, p90 and p99 latency, queue length, batch-size distribution and device utilization. Latency and throughput covers the measurement.
  • Watch the inputs. Log input statistics and prediction distributions so drift shows up (Monitoring and drift).
  • Roll out safely. Version the model, shadow or canary a new version against the old one, and keep the old one loadable for rollback.

Worked example

Four batching policies on one arrival trace

Sixteen requests arrive at 0, 1, 2, 4, 5, 7, 8, 9, 12, 13, 15, 16, 18, 19, 21, 22 ms (roughly one every 1.5 ms). One batch of B items takes 5 + 1·B ms. Latency is finish time minus arrival time. The cost model is invented to make the arithmetic visible; real devices have the same shape of curve but their own constants.

PolicyBatch sizesMean (ms)p50p90MaxAll done (ms)Requests/s
one at a time16 × 140.2539697496167
batch of up to 4, no wait1, 4, 4, 4, 315.3115202241390
batch of up to 4, wait 3 ms3, 4, 4, 4, 115.8116222244364
batch of up to 8, wait 3 ms3, 5, 7, 114.7514202139410

One at a time, each request costs 6 ms but they arrive every 1.5 ms on average, so the queue keeps growing and the 90th percentile waits 69 ms. A batcher with no wait takes whatever has queued when the device frees: the first request runs alone (nothing else has arrived), then the backlog fills batches of 4 until the queue drains. Mean latency drops to 15.31 ms and throughput more than doubles.

Adding a 3 ms wait makes the first batch hold until the 3 ms deadline and run the three requests that have arrived by then, but the device sits idle for those 3 ms, and that delay pushes every later batch back: mean latency rises to 15.81 ms. Under this load the wait buys nothing. Raising the maximum to 8 lets the batcher absorb the backlog in bigger batches (3, 5, 7, 1) and gives the best mean, 14.75 ms, and the best throughput.

When each policy runs its batches Time runs left to right, six pixels per millisecond. The top row marks the sixteen arrivals. Below it, one lane per policy shows each batch as a box from its start to its end, labelled with its size. One at a time runs sixteen back-to-back boxes ending at 96 ms. Up to 4 with no wait ends at 41 ms, up to 4 with a 3 ms wait at 44 ms, and up to 8 with a 3 ms wait at 39 ms. arrivals one at a time ≤ 4, wait 0 ms 4 4 4 3 ≤ 4, wait 3 ms 3 4 4 4 ≤ 8, wait 3 ms 3 5 7 0 ms 24 ms 48 ms 72 ms 96 ms

CPU against GPU on the same cost model

Two invented devices: a CPU at 0.5 + 2.0·B ms and a GPU at 3.0 + 0.05·B ms.

BatchCPU latency (ms)CPU items/sGPU latency (ms)GPU items/s
12.504003.05328
48.504713.201250
1632.504923.804211
64128.504986.2010323

At batch size 1 the CPU answers in 2.50 ms against the GPU's 3.05, and serves more requests per second. The CPU's throughput is capped near 1/2.0 ms = 500 items/s whatever the batch; the GPU's keeps climbing, reaching 10323 items/s at batch 64 with latency still only 6.2 ms. The GPU pays off only when traffic fills its batches.

Static against continuous batching

Eight generation requests, as (arrival step, tokens to generate): r0 (0, 3), r1 (0, 9), r2 (0, 4), r3 (0, 2), r4 (1, 5), r5 (2, 3), r6 (3, 6), r7 (4, 2), and 4 batch slots. Each step generates one token for every running request.

SchedulingFinish step per requestMean latency (steps)Total stepsSlot use
Static3, 9, 4, 2, 14, 12, 15, 117.5001534 / (15 × 4) = 57%
Continuous3, 9, 4, 2, 7, 6, 10, 84.8751034 / (10 × 4) = 85%

Both generate the same 34 tokens. Static batching runs r0 to r3 together until r1's 9 tokens are done, so from step 4 three slots idle while r4 to r7 wait. Continuous batching starts r4 the step after r3 finishes, and so on, finishing in 10 steps instead of 15 and cutting mean latency from 7.500 to 4.875 steps. The first four requests are unaffected: the gain is entirely in not making later requests wait for the slowest one.

Implementation

Export a model once with a dynamic batch dimension, save it, and serve it from a process that never imports the model's class. The batcher is a minimal version of the policy above: block for the first request, then keep collecting until the batch is full or the deadline passes.

import queue, threading, time
import torch
from torch.export import Dim

# offline, in the training environment
model.eval()
example = (torch.randn(4, 128),)
ep = torch.export.export(model, example,
                         dynamic_shapes={"x": {0: Dim("batch", min=1, max=64)}})
torch.export.save(ep, "model.pt2")
# optional: a package that runs without Python
# torch._inductor.aoti_compile_and_package(ep, package_path="model_aoti.pt2")

# serving process
served = torch.export.load("model.pt2").module()   # trusted file only: load uses pickle

requests = queue.Queue()                           # items: (input tensor [128], reply queue)

def batcher(max_batch=32, max_wait_s=0.003):
    while True:
        batch = [requests.get()]
        deadline = time.monotonic() + max_wait_s
        while len(batch) < max_batch:
            left = deadline - time.monotonic()
            if left <= 0:
                break
            try:
                batch.append(requests.get(timeout=left))
            except queue.Empty:
                break
        x = torch.stack([item for item, _ in batch])
        with torch.inference_mode():
            y = served(x)
        for (_, reply), out in zip(batch, y):
            reply.put(out)

threading.Thread(target=batcher, daemon=True).start()

def predict(features):
    reply = queue.Queue(maxsize=1)
    requests.put((features, reply))
    return reply.get()

For plain weights the safe load is model.load_state_dict(torch.load("weights.pt", map_location="cpu", weights_only=True)). Production servers (TorchServe, Triton, vLLM, and others) add what this sketch leaves out: per-request timeouts, back-pressure when the queue is too long, multiple model replicas, metrics, and for generation, continuous batching over a paged KV cache.

Tradeoffs

  • Eager vs compiled vs exported. Eager is the easiest to debug and change. Compile is a one-line speedup that stays in Python but adds warm-up time and recompiles. Export gives a portable, Python-free artifact but demands a single graph with no data-dependent Python control flow, which can mean rewriting parts of the model.
  • Batch size. Larger batches raise throughput and lower cost per request, but every request in a batch waits for the whole batch, so latency rises with it.
  • Batching wait. A wait fills batches at light load and only adds latency at heavy load. Many systems keep it at a few milliseconds or tune it adaptively.
  • Static vs dynamic shapes. Specialized shapes give the fastest kernels and CUDA graphs but need a graph per shape; dynamic shapes give one graph that is somewhat slower. Padding inputs to a few bucket sizes is a common middle ground.
  • CPU vs GPU. CPUs win on small models, low traffic and strict per-request latency; GPUs win when the model is large and traffic fills batches.
  • Portability. The exported .pt2 format is still evolving and may not load in a newer PyTorch, so keep the training code and weights to re-export from.
A served model's outputs change depending on which other requests share its batch. What is the likely bug?

The model was not put in eval() mode, so batch norm normalizes with the current batch's statistics (and dropout may be active). Call model.eval() before exporting or serving.

The first few requests after each deploy take seconds; later ones take milliseconds. Why, and what fixes it?

torch.compile captures and compiles on the first call for each new input shape. Send warm-up requests covering every shape bucket before the instance joins the load balancer, or ship an AOTInductor package compiled ahead of time.

An exported model works with batch size 4 and fails for every other batch size. What was missed?

Export specializes on the example input's shapes unless told otherwise. Declare the batch dimension in dynamic_shapes, for example {"x": {0: Dim("batch", min=1, max=64)}}.

In the worked example, adding a 3 ms wait made latency worse. When would it help?

At light load, when requests arrive slower than the device can run them. Then the queue is usually empty when the device frees, and without a wait almost every batch has one request. A short wait collects a few more and spends the fixed cost once for all of them.

Connections

Read first

Read next

References

  • Crankshaw, Wang, Zhou, Franklin, Gonzalez and Stoica. Clipper: A Low-Latency Online Prediction Serving System. NSDI 2017. usenix.org (Adaptive dynamic batching under a latency target.)
  • Yu, Jeong, Kim, Kim and Chun. Orca: A Distributed Serving System for Transformer-Based Generative Models. OSDI 2022. usenix.org (Iteration-level scheduling, now called continuous batching.)
  • Kwon, Li, Zhuang, Sheng, Zheng, Yu, Gonzalez, Zhang and Stoica. Efficient Memory Management for Large Language Model Serving with PagedAttention. SOSP 2023. arXiv:2309.06180
  • Ansel et al. PyTorch 2: Faster Machine Learning Through Dynamic Python Bytecode Transformation and Graph Compilation. ASPLOS 2024. doi:10.1145/3620665.3640366 (TorchDynamo and TorchInductor.)
  • PyTorch v2.15.0: torch.export.export, Dim, ExportedProgram.module, torch.compile, aoti_compile_and_package, torch.load, inference_mode.