Optimizing AI Model Inference Latency in Production Using NVIDIA Triton Inference Server
Where latency actually goes
Naive model serving (a Flask/FastAPI endpoint calling model.predict() per request) leaves throughput and latency on the table in three specific ways: no request batching (GPU sits mostly idle between single-sample calls), no concurrent execution of multiple model instances, and no graph-level optimization of the model itself. NVIDIA Triton Inference Server addresses all three with a model repository convention, dynamic batching, and support for optimized backends like ONNX Runtime and TensorRT. This tutorial converts a PyTorch classification model to ONNX, configures Triton for dynamic batching and multiple instances, and load-tests the result.
1. Technical architecture
┌────────────────┐ ┌──────────────────────────────────────┐
│ Client requests │───▶ │ Triton Inference Server │
│ (gRPC or HTTP) │ │ ┌────────────────────────────────────┐ │
└────────────────┘ │ │ Dynamic batcher (queues requests, │ │
│ │ forms batches up to max_batch_size, │ │
│ │ bounded by max_queue_delay_ms) │ │
│ └───────────────────┬────────────────┘ │
│ ▼ │
│ ┌────────────────────────────────────┐ │
│ │ N concurrent model instances │ │
│ │ (instance_group, e.g. 2x on GPU 0) │ │
│ │ running the ONNX Runtime backend │ │
│ └───────────────────┬────────────────┘ │
└────────────────────────┼──────────────────┘
▼
┌────────────────────────┐
│ Response returned to │
│ client + Prometheus metrics │
│ (latency, queue time, etc.) │
└────────────────────────┘
2. Complete code implementation
2.1 Export the PyTorch model to ONNX
# export/export_onnx.py
import torch
import torch.onnx
from model_def import ClassifierModel # your existing PyTorch model class
CHECKPOINT_PATH = "checkpoints/classifier.pt"
ONNX_OUTPUT_PATH = "model_repository/classifier/1/model.onnx"
model = ClassifierModel()
model.load_state_dict(torch.load(CHECKPOINT_PATH, map_location="cpu"))
model.eval()
dummy_input = torch.randn(1, 3, 224, 224) # match your real input shape
torch.onnx.export(
model,
dummy_input,
ONNX_OUTPUT_PATH,
export_params=True,
opset_version=17,
do_constant_folding=True,
input_names=["input"],
output_names=["output"],
dynamic_axes={
"input": {0: "batch_size"}, # allow variable batch size — required for dynamic batching
"output": {0: "batch_size"},
},
)
print(f"Exported ONNX model to {ONNX_OUTPUT_PATH}")
2.2 Triton model repository configuration
model_repository/
└── classifier/
├── config.pbtxt
└── 1/
└── model.onnx
# model_repository/classifier/config.pbtxt
name: "classifier"
platform: "onnxruntime_onnx"
max_batch_size: 32
input [
{
name: "input"
data_type: TYPE_FP32
dims: [ 3, 224, 224 ]
}
]
output [
{
name: "output"
data_type: TYPE_FP32
dims: [ 1000 ]
}
]
dynamic_batching {
preferred_batch_size: [ 8, 16, 32 ]
max_queue_delay_microseconds: 5000 # wait up to 5ms to fill a preferred batch size
}
instance_group [
{
count: 2
kind: KIND_GPU
gpus: [ 0 ]
}
]
optimization {
execution_accelerators {
gpu_execution_accelerator: [
{
name: "tensorrt"
parameters { key: "precision_mode" value: "FP16" }
}
]
}
}
2.3 Python client with connection pooling and retries
# client/triton_client.py
import numpy as np
import tritonclient.grpc as grpcclient
from tritonclient.utils import InferenceServerException
MODEL_NAME = "classifier"
TRITON_URL = "localhost:8001"
class TritonClassifierClient:
def __init__(self, url: str = TRITON_URL, timeout_seconds: float = 2.0):
self.client = grpcclient.InferenceServerClient(url=url)
self.timeout_us = int(timeout_seconds * 1_000_000)
if not self.client.is_server_live():
raise RuntimeError(f"Triton server at {url} is not live")
if not self.client.is_model_ready(MODEL_NAME):
raise RuntimeError(f"Model '{MODEL_NAME}' is not ready on the Triton server")
def predict(self, batch: np.ndarray, max_retries: int = 2) -> np.ndarray:
"""batch shape: (N, 3, 224, 224), dtype float32"""
infer_input = grpcclient.InferInput("input", batch.shape, "FP32")
infer_input.set_data_from_numpy(batch.astype(np.float32))
infer_output = grpcclient.InferRequestedOutput("output")
for attempt in range(max_retries + 1):
try:
result = self.client.infer(
model_name=MODEL_NAME,
inputs=[infer_input],
outputs=[infer_output],
client_timeout=self.timeout_us,
)
return result.as_numpy("output")
except InferenceServerException as exc:
message = str(exc)
if "Deadline Exceeded" in message and attempt < max_retries:
continue # transient — retry within the caller's own timeout budget
if "model is not ready" in message.lower():
raise RuntimeError("Model temporarily unloading/reloading — retry later") from exc
raise
3. Step-by-step configuration guide
- Install export/client dependencies:
pip install torch onnx tritonclient[grpc] numpy --break-system-packages - Export your model:
python export/export_onnx.py, then verify withpython -c "import onnx; onnx.checker.check_model(onnx.load('model_repository/classifier/1/model.onnx'))". - Build the model repository exactly as shown in §2.2 — Triton requires the
<model_name>/<version>/model.onnxlayout with a siblingconfig.pbtxt. - Run Triton via Docker:
docker run --gpus all --rm -p 8000:8000 -p 8001:8001 -p 8002:8002 \ -v $(pwd)/model_repository:/models \ nvcr.io/nvidia/tritonserver:24.05-py3 \ tritonserver --model-repository=/models - Confirm the model loaded:
curl -s localhost:8000/v2/health/readyshould return 200, andcurl -s localhost:8000/v2/models/classifiershould show"state": "READY". - Load test with Triton's built-in
perf_analyzerto find the batching sweet spot:
Read the p99 latency vs. concurrency table it prints — the goal is to find the highest concurrency where p99 stays under your SLA before latency knees upward.perf_analyzer -m classifier -u localhost:8001 -i grpc \ --concurrency-range 1:16:2 --measurement-interval 5000 - Tune
preferred_batch_sizeandmax_queue_delay_microsecondsinconfig.pbtxtbased on the perf_analyzer results, then restart Triton and re-run the load test to confirm the improvement.
4. Error handling and edge cases
- Model not ready at client startup —
TritonClassifierClient.__init__explicitly checksis_server_live()andis_model_ready()and raises immediately with a clear message, rather than letting the first real inference call fail with an opaque gRPC error. - Request timeout under load (
Deadline Exceeded) is retried once by default viamax_retries, since it's often caused by a transient batch-queue spike rather than a genuine failure — but the retry count is capped so a persistently overloaded server still surfaces the error to the caller instead of retrying forever. - Model reload/unload race — if a model is being hot-reloaded (e.g., during a version rollout), inference calls can transiently return a "model is not ready" error; this is caught explicitly and re-raised as a distinct, retryable
RuntimeErrorrather than the generic Triton exception, making it easy for callers to distinguish from a real input-shape error. - Dynamic axis mismatch — if
dynamic_axesis omitted during ONNX export, Triton's dynamic batcher cannot vary the batch dimension and every request must match the exact traced batch size; always export with the batch dimension marked dynamic as shown in §2.1. max_queue_delay_microsecondstoo high — setting this too aggressively (e.g., 50ms+) trades latency for throughput; for latency-sensitive endpoints, start low (2-5ms) and only raise it ifperf_analyzershows meaningfully better throughput at acceptable latency cost.- FP16 precision issues — the TensorRT
FP16accelerator inconfig.pbtxtcan shift model outputs slightly versus FP32; re-run your accuracy/eval suite against the accelerated model before shipping, and fall back to FP32 (or drop theoptimizationblock) if accuracy regresses beyond your tolerance. - GPU memory exhaustion from too many instances — each entry in
instance_group.countloads a full copy of the model; if Triton fails to start with an out-of-memory error, reducecountor move some instances toKIND_CPUfor a lower-traffic model sharing the same server.