Triton Inference Server: Production ML Serving That Actually Scales
Somewhere between “we trained a model” and “we serve a hundred thousand requests per second” there is a gulf that swallows weekends. Serving inference at scale is not a solved problem you inherit from a tutorial. You will hit batching strategy, memory fragmentation, GPU utilization, model format conversions, dynamic shapes, request concurrency, and half a dozen other things the notebook never warned you about.
NVIDIA Triton Inference Server is the most complete open-source answer to that problem. It is the workhorse behind a huge fraction of production ML serving at major AI companies and cloud providers. It runs inside SageMaker endpoints, Vertex AI, Azure ML, and countless on-prem deployments. If you serve ML models to real users, there is a good chance you will meet Triton eventually.
This post is a tour of what Triton is, how it thinks, and what the hard parts look like in production.
What Triton actually is
Triton is a C++ inference server that loads models from a directory structure and exposes them over HTTP and gRPC. Out of the box it supports multiple backends: TensorRT, ONNX Runtime, PyTorch (TorchScript and TorchExport), TensorFlow, OpenVINO, Python, vLLM, TensorRT-LLM, FIL (forest/XGBoost), and DALI for data loading. A single Triton process can serve models in any mix of these formats simultaneously — a TensorRT-accelerated CV model next to a Python-backed tokenizer next to a vLLM LLM.
The core value is consistency: the same API surface, the same batching logic, the same metrics, the same deployment pattern across every model framework your org might use. You stop building a bespoke Flask/FastAPI server per model.
Architecturally, Triton is three things:
- The server process — a multi-threaded C++ core that handles request lifecycle, model management, dynamic batching, and metrics. One binary.
- Backends — shared libraries (
.sofiles) that know how to execute a specific framework’s models. Triton loads them on demand. - Model repository — a directory on disk (local, S3, GCS, Azure Blob) that Triton reads. Each model is a subdirectory containing a config file and one or more numbered version directories.
The server is stateless with respect to requests, but it is not stateless with respect to models — it keeps model artifacts loaded in CPU or GPU memory. Restarting Triton means reloading everything, which can be a minutes-long operation for large LLMs.
The model repository layout
Triton’s config-by-convention is a directory tree. A minimal repo:
models/
├── resnet50/
│ ├── config.pbtxt
│ └── 1/
│ └── model.plan # TensorRT engine
├── bert-tokenizer/
│ ├── config.pbtxt
│ └── 1/
│ └── model.py # Python backend
└── bert-base/
├── config.pbtxt
└── 1/
└── model.onnx # ONNX Runtime backend
Each model has:
- A config.pbtxt in protobuf text format describing inputs, outputs, batching, instance groups, and backend specifics.
- Numbered version directories (
1/,2/,3/). Triton’s version policy decides which get loaded. - A backend-specific artifact inside the version directory:
model.planfor TensorRT,model.onnxfor ONNX,model.ptfor TorchScript,model.pyfor the Python backend, etc.
A minimal config for a TensorRT image classifier:
|
|
The dims are the shape excluding the batch dimension. max_batch_size: 64 means Triton can batch up to 64 requests together before passing them to the backend. instance_group says to run two copies of the model on GPU 0 — allowing two concurrent executions per request wave. dynamic_batching tells Triton to collect requests for up to 5 ms or until a preferred batch size is reached.
This last knob is the single most important thing you will tune.
Dynamic batching: where the free throughput lives
An ML model running on a GPU has a fixed per-call overhead (kernel launch, memory transfers, Python-to-C++ hops). Processing one request at a time wastes this overhead. Processing 32 requests together amortizes it across all of them. Your latency goes up slightly, your throughput goes up 10–30×.
Dynamic batching is Triton’s scheduler that accumulates requests on the way into a model. Three knobs:
preferred_batch_size— target batch sizes the scheduler tries to hit. List them in increasing order. Triton will prefer these sizes when batching.max_queue_delay_microseconds— the longest Triton will hold a request waiting for more to batch with. Under 10 ms is common for online serving; longer is fine for offline workloads.priority_levels(optional) — give certain request types priority in the queue.
The tradeoff is purely latency vs throughput. The P50 request will see roughly max_queue_delay + model_compute_time. Setting delay to zero disables batching and hands every request straight to the model. Setting it high (50 ms, 100 ms) maximizes throughput for offline batch inference.
For LLMs, Triton has iteration-level batching (in-flight batching) via the TensorRT-LLM and vLLM backends. This is a fundamentally different scheme: new requests join a running batch between generation steps, and finished requests drop out immediately. With autoregressive models where sequences finish at different rates, in-flight batching is 5–10× the throughput of classical dynamic batching.
Instance groups and GPU utilization
A single model instance on a GPU typically cannot saturate the hardware — especially smaller models, where a forward pass is 1 ms and the GPU has nothing to do the other 99% of the time. instance_group tells Triton to load multiple copies of the model, each serving requests concurrently.
|
|
This loads 4 instances on each of GPU 0 and GPU 1 — 8 total. Requests go to whichever instance is free. The tradeoffs:
- More instances = higher memory use. Each instance has its own weights and working memory. A 20 GB model × 4 instances is 80 GB, which doesn’t fit on most GPUs.
- More instances = better concurrency. A slow request stops blocking everyone. For models with variable latency (e.g., variable-length LLM prompts), this matters.
- Diminishing returns past GPU saturation. Measure. Two instances is often the sweet spot for compute-bound CNNs.
KIND_CPU and KIND_MODEL are also valid. KIND_MODEL delegates device choice to the backend (useful for backends like vLLM that manage their own GPU topology).
Model ensembles: composing pipelines
Many real inference pipelines are not a single model. They are: tokenize → embed → rank → decode. Triton has two ways to express this.
Ensembles are declarative pipelines defined in a config file. They compose models, routing outputs of one as inputs to the next, and run the whole pipeline in-process without network hops between steps:
|
|
Each step can have its own batching, instance groups, and backend. The ensemble handles routing.
Business Logic Scripting (BLS) is the Python-backend alternative: write Python that makes calls to other Triton models via pb_utils.InferenceRequest. More expressive (conditional branches, loops, error handling), slower (Python in the request path). Use ensembles when the pipeline is straight-through; use BLS when it has actual logic.
Composition over network hops is one of Triton’s real wins. A Python service calling three models is three HTTP round-trips and three serialization passes. An ensemble is one request with zero internal hops, same memory space.
The Python backend: escape hatch and sharp edge
Triton’s Python backend lets you write a model as a Python class:
|
|
Behind the scenes, Triton launches a Python subprocess per model instance and communicates via shared memory. Each request crosses that boundary. For pure-Python work (tokenization, simple post-processing) the overhead is negligible. For heavy GPU operations, use a native backend — Python is not the right choice for a ResNet.
Two things to know:
- The Python environment is resolved via
EXECUTION_ENV_PATHin config or aconda-packed environment. Do not assume your model will pick up the system Python. Wrong Python environments are the #1 cause of “my Python model won’t load” tickets. - The Python backend supports batching and decoupled execution. Prefer
executeoperating on a list of requests (implicit batching) rather than a per-request loop.
Metrics, logging, and observability
Triton exports Prometheus metrics on /metrics (port 8002 by default). The important ones:
nv_inference_request_success/nv_inference_request_failure— per-model request counts.nv_inference_queue_duration_us— how long requests waited in the batching queue. Spikes here mean the backend is backed up.nv_inference_compute_duration_us— actual compute time. Monotonically increasing means model degradation or contention.nv_gpu_utilization,nv_gpu_memory_used_bytes— per-GPU stats.nv_inference_exec_count— how many times the backend was invoked. Compared to request count, shows batching efficiency.
The canonical dashboard shows, per model: request rate, error rate, queue time P95, compute time P95, batch size distribution, GPU utilization. Add SLO burn rate alerts on error rate and P95 latency.
Triton also supports distributed tracing via OpenTelemetry. Set --trace-config flags to export spans for the request lifecycle including model-internal stages for ensembles. Huge time-saver for debugging slow pipelines.
Logs go to stdout and can be leveled per subsystem (--log-verbose=1 for debug). The logs are terse — most of the diagnostic signal is in metrics and traces.
Model management: loading, unloading, versioning
Triton supports three model control modes:
- NONE (default) — all models are loaded at startup. Simplest. Fine for static deployments with modest model counts.
- EXPLICIT — no models loaded at startup. You load/unload via the management API. Right for multi-tenant systems where which models are needed is dynamic.
- POLL — periodically re-scan the model repository. Auto-load new models, unload removed ones. Convenient for dev, unsafe for prod (race conditions on disk updates).
The model management API lets you push new versions and flip traffic without restarting Triton:
POST /v2/repository/models/bert-base/load
POST /v2/repository/models/bert-base/unload
Combined with version policies (all, latest: n, specific: [versions]), you can canary new versions in by loading v2 alongside v1 and shifting traffic at the client level.
TensorRT: the performance ceiling
For CUDA-capable GPUs, TensorRT is the fastest backend. It JITs your ONNX/PyTorch model into an optimized CUDA engine, applies layer fusion, picks kernel implementations, and supports FP16/INT8/FP8 quantization. A TensorRT-optimized ResNet-50 is typically 3–5× the throughput of the same model in ONNX Runtime, and 8–15× PyTorch eager.
The price: engines are compiled for specific GPU architectures. An engine built on an A100 won’t run on an H100 (sometimes it will, sometimes it won’t). For fleets with mixed GPUs, build per-architecture engines and match them at deployment. TensorRT engines are also sensitive to input shapes — dynamic shapes require optimization profiles that tell TensorRT the range of shapes to optimize for.
A typical conversion pipeline:
pytorch_model.pt
→ torch.onnx.export() → model.onnx
→ trtexec --onnx=model.onnx --saveEngine=model.plan --fp16 --minShapes=... --optShapes=... --maxShapes=...
Then drop model.plan into the Triton repository. The conversion is not free — engines can take minutes to compile and are non-trivial to debug when they fail. Keep the ONNX intermediates under version control alongside the TensorRT build scripts.
LLM serving: TensorRT-LLM and vLLM backends
For transformer LLMs, the classical dynamic-batching story doesn’t fit. Sequences generate tokens one at a time at variable speeds; batch members finish at different moments; new requests want to join mid-flight. Triton supports two backends optimized for this:
TensorRT-LLM — NVIDIA’s LLM-specific TensorRT extension with in-flight batching, paged KV cache, speculative decoding, and aggressive quantization (INT4, FP8). Highest throughput on NVIDIA hardware. Painful to build — the engine compilation step requires matching the TensorRT-LLM version, transformers version, and the specific model architecture. Expect to spend time on this.
vLLM — open-source Python library with PagedAttention, continuous batching, and a growing model zoo. Integrated as a Triton backend. Much easier to get running than TensorRT-LLM. Slightly lower throughput at the high end but vastly better developer ergonomics. For most teams, start here.
For both, expose streaming responses via Triton’s decoupled mode. Clients get tokens as they are generated rather than waiting for the full sequence. Use gRPC with streaming for the best UX.
Deployment: the real stuff
In practice, Triton lives in a container. NVIDIA publishes nvcr.io/nvidia/tritonserver:YY.MM-py3 images monthly. Pin to a specific tag — drift between versions can subtly change batching or backend behavior.
Kubernetes deployments use a StatefulSet (stable identity, ordered startup) or a Deployment with appropriate probes. The liveness probe should hit /v2/health/live (server up) and readiness should hit /v2/health/ready (all models loaded). For large models, set initialDelaySeconds generously — a 70B LLM on cold boot can take 2+ minutes to load weights.
For autoscaling, horizontal pod autoscaler on GPU utilization or queue depth works reasonably. Better: use KEDA with Triton’s Prometheus metrics for scale-on-queue-duration, which is a direct measure of “am I saturated.”
Node selection matters a lot with GPUs. Use nodeSelector for GPU type (nvidia.com/gpu.product: NVIDIA-A100-SXM4-80GB), resource requests for GPU count, and taints/tolerations to keep non-GPU workloads off your expensive nodes.
The Triton Model Analyzer is worth knowing about: a profiling tool that sweeps through batch sizes, instance counts, and dynamic batching delays, measuring throughput and latency for each combination. Run it against your models and use the results to set production configs. It will save you weeks of manual tuning.
Request patterns: HTTP, gRPC, and the shared-memory trick
Triton speaks HTTP (REST-like with JSON payloads) and gRPC. Use gRPC for anything latency-sensitive — the binary protocol cuts serialization overhead significantly.
For extreme latency cases (sub-millisecond) on the same host, Triton supports CUDA shared memory and system shared memory. The client allocates a shared memory region, writes inputs directly, and hands Triton a pointer. Zero-copy request path. Saves 1–2 ms per call for large inputs. Rarely necessary, occasionally a lifesaver.
The Python and C++ client libraries handle all of this. Don’t hand-roll HTTP requests unless you have a reason — the client libraries understand shape inference, dynamic batching semantics, and streaming.
What Triton doesn’t do
Being clear about scope:
- No feature store. You still need your own pipeline for online features.
- No authentication. Triton assumes an upstream gateway handles auth. Run it behind an API gateway (Envoy, Kong, API Gateway, or your service mesh) for anything internet-facing.
- No A/B testing or traffic splitting beyond version policies. Your gateway or service mesh does this.
- No model registry. The model repository is a file tree. Integrate with MLflow, Vertex Model Registry, SageMaker, or a bespoke system for lifecycle tracking.
- No training. It is a serving-only system.
Alternatives and when they fit
vLLM standalone — if you serve only LLMs and want minimum fuss, vLLM’s own OpenAI-compatible server is simpler than Triton+vLLM. You lose multi-model, multi-backend serving.
KServe — Kubernetes-native serving with Triton as one of its backends. Adds autoscaling (including scale-to-zero), traffic splitting, canary deployment. Good if you’re Kubernetes-heavy and want those extras. More moving parts.
BentoML — Python-first, Pythonic API for building bento services. Lower performance ceiling than Triton, much nicer developer experience for Python-only teams.
TorchServe — PyTorch-specific. Dead-end if you plan to run anything else.
Ray Serve — Python-based, integrates with Ray’s distributed primitives. Great for Python-native pipelines; Triton wins on single-model throughput.
For a multi-framework org serving CV, NLP, and LLM models at scale, Triton remains the most complete option.
A pragmatic rollout
For a team adopting Triton from scratch:
- Start with one model, ONNX Runtime or PyTorch backend. Get it serving via Triton in a container locally.
- Add dynamic batching. Measure throughput before and after. This is where the gains come from.
- Set up metrics scraping and a basic dashboard before you add more models.
- Convert performance-critical models to TensorRT. Keep the ONNX intermediate in version control.
- Add the first Python backend (tokenizer, pre/post-processing). Compose with the native backend via an ensemble.
- Kubernetes deployment with proper probes, node selectors, and HPA.
- Run Triton Model Analyzer against each model before rolling to production.
- For LLMs, try vLLM backend first; graduate to TensorRT-LLM if you need the last 30% throughput and have the engineering time.
- Audit metrics for queue time regularly. Increasing queue time is the early signal that capacity is running out.
- Plan for model versioning and rollback. Use explicit load mode in production rather than polling.
Triton is not the friendliest tool to learn. The config format is non-obvious, error messages are frequently cryptic, and the gap between “works in dev” and “works in prod” is wider than most frameworks. But the ceiling is high. A well-tuned Triton deployment routinely serves 10× the QPS-per-GPU of hand-rolled Flask endpoints, and the gap widens as you add batching, multiple models, and LLM workloads. If inference is a real part of your workload, the investment pays back quickly.
Comments