Serving embeddings at scale with ONNX Runtime
Last quarter, my team took over the real-time feature generation pipeline for our order-book anomaly detection models. The core of the pipeline relies on generating dense embeddings from sequence data (order-book deltas, trade prints, and quote updates) to feed downstream trade-execution models.
When we inherited the service, it was running raw PyTorch inside a FastAPI application deployed across 12 GPU-enabled Kubernetes nodes. The service was choking: at 2,500 requests per second (RPS), our p99 inference latency soared to 340ms, causing cascading timeouts in our execution routers. Worse, the GPU utilization hovered at a pathetic 14% because of Python GIL contention and unoptimized tensor operations.
We rebuilt the service using ONNX Runtime (ORT) coupled with dynamic batching, tokenization optimizations, and CUDA execution provider tuning. We eliminated PyTorch from the production path, dropped our p99 latency to 18ms at 8,500 RPS, and decommissioned 8 of our 12 GPU nodes.
Here is the exact architecture, the technical dead-ends we hit along the way, and the code you need to replicate these results.
The Bottleneck: Why PyTorch in Production Failed Us
Our baseline setup used Hugging Face’s transformers library directly in PyTorch (torch.no_grad()) to compute 384-dimensional embeddings from mini-batches of market data sequences.
p50 Latency: 42ms
p99 Latency: 340ms
Hardware: 12x NVIDIA A10G GPUs
When we profiled the Python processes using py-spy and NVIDIA Nsight Systems, three bottlenecks dominated the trace:
- GIL and Web Framework Overhead: FastAPI workers spent almost 35% of their execution time marshaling JSON payloads, converting Python lists to PyTorch tensors, and waiting on the Python Global Interpreter Lock (GIL).
- Suboptimal Graph Execution: PyTorch’s dynamic computational graph introduced non-negligible interpreter overhead per forward pass. Kernels were being launched individually without aggressive layer fusion (e.g., combining LayerNorm and Attention operations).
- Memory Allocation Churn: Repeatedly allocating GPU memory buffers for dynamic batch sizes caused memory fragmentation and synchronous host-to-device copy delays.
Our first impulse was to scale horizontally by adding more pods. That drove our AWS bill through the roof without fixing the long-tail latency p99 spikes caused by PyTorch’s CUDA stream synchronized calls. We needed a static, highly optimized graph representation and a zero-Python inference loop.
The Target Architecture
We shifted to a two-phase architecture: an offline export pipeline that bakes static optimizations into an ONNX graph, and an asynchronous C++ runtime wrapper invoked via a thin Python interface (using onnxruntime-gpu).
flowchart LR client["Client Request"] -->|"gRPC Payload"| gateway["API Gateway"] gateway -->|"Raw Strings"| tok["Fast Tokenizer"] tok -->|"Int32 Tensors"| batcher["Dynamic Batcher"] batcher -->|"Batched Tensors"| ort["ONNX Runtime"] ort -->|"CUDA Execution"| gpu["NVIDIA Tensor Core"] gpu -->|"FP16 Embeddings"| client
By decoupling tokenization from tensor execution and leveraging ONNX Runtime’s custom CUDA allocations, we created a pipeline where incoming sequences stay entirely in continuous memory blocks from request receipt to embedding extraction.
Step 1: Exporting and Optimizing the Graph
Exporting a model from PyTorch to ONNX sounds trivial using torch.onnx.export, but if you rely on default settings, you end up with an unoptimized graph that performance-regresses compared to native PyTorch.
Here is the script we built to export a SentenceTransformers-style model (all-MiniLM-L6-v2) to ONNX with dynamic axes and custom ONNX opset definitions.
import torch
from transformers import AutoTokenizer, AutoModel
MODEL_ID = “sentence-transformers/all-MiniLM-L6-v2”
OUTPUT_PATH = “model.onnx”
def export_model():
print(f”Loading model {MODEL_ID}…”)
tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)
model = AutoModel.from_pretrained(MODEL_ID)
model.eval()
# Dummy input for trace instantiation
dummy_text = “LIMIT_BUY AAPL 100 @ 180.50”
inputs = tokenizer(dummy_text, return_tensors=“pt”)
symbolic_names = {0: “batch_size”, 1: “sequence_length”}
print(“Exporting model to ONNX…”)
torch.onnx.export(
model,
(inputs[“input_ids”], inputs[“attention_mask”], inputs[“token_type_ids”]),
OUTPUT_PATH,
opset_version=17,
do_constant_folding=True,
input_names=[“input_ids”, “attention_mask”, “token_type_ids”],
output_names=[“last_hidden_state”],
dynamic_axes={
“input_ids”: symbolic_names,
“attention_mask”: symbolic_names,
“token_type_ids”: symbolic_names,
“last_hidden_state”: symbolic_names,
},
)
print(f”Successfully exported to {OUTPUT_PATH}”)
if __name__ == “__main__”:
export_model()
Graph Optimization and FP16 Quantization
Exporting is only half the battle. Raw ONNX graphs contain redundant node operations. We used the onnxruntime.transformers optimizer to fuse Multi-Head Attention blocks, GELU activations, and LayerNorm layers into optimized single CUDA kernels.
import onnx
from onnxruntime.transformers import optimizer
from onnxruntime.transformers.fusion_options import FusionOptions
def optimize():
input_model = “model.onnx”
output_model = “model_optimized_fp16.onnx”
opt_options = FusionOptions(“bert”)
opt_options.enable_gelu_approximation = True
optimized_model = optimizer.optimize_model(
input_model,
model_type=“bert”,
num_heads=12,
hidden_size=384,
optimization_options=opt_options,
)
# Convert to FP16 mixed precision for Tensor Core acceleration
optimized_model.convert_float_to_float16(keep_io_types=False)
optimized_model.save_model_to_file(output_model)
print(f”Optimized FP16 model saved to {output_model}”)
if __name__ == “__main__”:
optimize()
When running optimize_onnx.py, inspect the node count reduction:
Status: Optimizing BERT-like graph architecture…
Nodes before optimization: 312
Nodes after graph fusion: 94
Converted precision: FP32 -> FP16
Saved: model_optimized_fp16.onnx
Going from 312 nodes to 94 reduced our CUDA kernel invocation overhead by over 60%.
Step 2: High-Performance Production Engine
A fast graph is useless if your wrapper code introduces standard Python latency overhead. Below is our complete production-grade embedding server pipeline implementing tokenization, pinned memory allocations, mean pooling, and ONNX Runtime execution provider configuration.
import os
import numpy as np
import onnxruntime as ort
from tokenizers import Tokenizer
import time
class FastEmbeddingEngine:
def __init__(self, model_path: str, tokenizer_path: str):
# 1. High-throughput Tokenizer configuration
self.tokenizer = Tokenizer.from_file(tokenizer_path)
self.tokenizer.enable_padding(direction=“right”, pad_id=0, pad_token=“[PAD]”)
self.tokenizer.enable_truncation(max_length=128)
# 2. Configure ONNX Runtime Session Options
opts = ort.SessionOptions()
opts.execution_mode = ort.ExecutionMode.ORT_SEQUENTIAL
opts.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
opts.enable_cpu_mem_arena = False
# Micro-optimization: Inter/Intra thread tuning
opts.intra_op_num_threads = 2
opts.inter_op_num_threads = 2
# 3. Execution Provider Tuning for CUDA
cuda_provider_options = {
“device_id”: 0,
“arena_extend_strategy”: “kNextPowerOfTwo”,
“gpu_mem_limit”: 4 * 1024 * 1024 * 1024, # 4GB limit
“cudnn_conv_algo_search”: “EXHAUSTIVE”,
“do_copy_in_default_stream”: True,
}
providers = [(“CUDAExecutionProvider”, cuda_provider_options), “CPUExecutionProvider”]
self.session = ort.InferenceSession(
model_path,
sess_options=opts,
providers=providers
)
self.input_names = [x.name for x in self.session.get_inputs()]
self.output_names = [x.name for x in self.session.get_outputs()]
def encode(self, texts: list[str]) -> np.ndarray:
# Step A: Fast Rust-backed Tokenization
encoded = self.tokenizer.encode_batch(texts)
input_ids = np.array([e.ids for e in encoded], dtype=np.int64)
attention_mask = np.array([e.attention_mask for e in encoded], dtype=np.int64)
token_type_ids = np.array([e.type_ids for e in encoded], dtype=np.int64)
# Step B: Feed ONNX Runtime Bindings
ort_inputs = {
“input_ids”: input_ids,
“attention_mask”: attention_mask,
“token_type_ids”: token_type_ids,
}
# Step C: Model Forward Pass
raw_outputs = self.session.run(self.output_names, ort_inputs)
last_hidden_state = raw_outputs[0] # Shape: (batch_size, seq_len, 384)
# Step D: Vectorized Mean Pooling (Accelerated)
mask_expanded = np.expand_dims(attention_mask, –1).astype(last_hidden_state.dtype)
sum_embeddings = np.sum(last_hidden_state * mask_expanded, axis=1)
sum_mask = np.clip(mask_expanded.sum(axis=1), a_min=1e-9, a_max=None)
embeddings = sum_embeddings / sum_mask
# Step E: L2 Normalization
norms = np.linalg.norm(embeddings, ord=2, axis=1, keepdims=True)
return embeddings / np.clip(norms, a_min=1e-12, a_max=None)
Real Dead-Ends We Encountered
We did not hit 8,500 RPS on our first try. Here are the three main failures that cost us days of debugging:
1. The IOBinding Trap with Variable Sequence Lengths
ONNX Runtime supports an advanced API called IOBinding, which allows pre-allocating GPU tensors to avoid memory copy overhead between Host (CPU) and Device (GPU).
We spent three days building an IOBinding queue. The result? Performance regressed by 45%.
Because market sequence payloads fluctuated dramatically in size (from 12 tokens to 128 tokens per batch), pre-allocated static GPU memory blocks had to be destroyed and re-allocated on almost every single request when shapes mismatched. IOBinding shines only when your batch dimensions and sequence lengths are 100% static. If your inputs have dynamic dimensions, standard host-to-device streaming via ONNX Runtime’s dynamic allocation pool is faster.
2. FP16 Underflow in L2 Normalization
When we converted our model directly to FP16, we started noticing occasional NaN embeddings in our output logs.
SELECT request_id, sequence_input, embedding_norm
FROM model_logs
WHERE isnan(embedding_norm);
— Result
req_992183 | “CANCEL_BUY TSLA 50 @ 240.10” | NaN
During L2 normalization, we compute the sum of squares ($\sum x_i^2$). In FP16, the maximum representable value is $65,504$. For a 384-dimensional vector, if multiple values exceeded $14.0$, intermediate square aggregations overflowed to $\infty$, yielding NaN after division.
The Fix: Keep model output vectors in FP16, but cast them to FP32 during the mean-pooling and L2-normalization compute steps in NumPy/C++.
3. CPU Core Over-Subscription
Initially, we ran 8 Gunicorn worker processes per container, assuming it would maximize GPU pipe throughput. Instead, throughput collapsed under high load. The issue was intra_op_num_threads. Each ONNX Runtime session spawned internal worker threads that fought over CPU cycles with Gunicorn workers.
Setting intra_op_num_threads = 2, inter_op_num_threads = 2, and deploying 2 workers per pod completely eliminated thread thrashing and dropped CPU context switches by 82%.
Benchmark Results: PyTorch vs. ONNX Runtime
We benchmarked our production setup using locust driving traffic against an AWS g5.xlarge instance (1x NVIDIA A10G GPU, 4 vCPUs, 16GB RAM). We tested across varying batch sizes with a fixed sequence length of 64 tokens.
Latency Profile under 5,000 RPS Load
| Framework | FP Precision | Batch Size | p50 Latency | p99 Latency | GPU Memory |
|---|---|---|---|---|---|
| PyTorch (Baseline) | FP32 | 16 | 38ms | 280ms | 2.8 GB |
| PyTorch + TorchScript | FP32 | 16 | 29ms | 195ms | 2.6 GB |
| ONNX Runtime (Naive) | FP32 | 16 | 14ms | 62ms | 1.1 GB |
| ONNX Runtime (Fused) | FP16 | 32 | 6ms | 18ms | 680 MB |
Throughput vs. Latency Curve
Latency
^
| / (PyTorch – breaks at 2,500 RPS)
300 | /
250 | /
200 | /
150 | /
100 | /
50 | ____________—-‘ (ONNX Runtime – scales smoothly)
0 +——————+——————+——————–>
0 3000 6000 9000 RPS
By leveraging fused graph operations and low-overhead thread scheduling, ONNX Runtime sustained 8,500 RPS within our strict < 20ms p99 SLO.
Key Takeaways
- Export is not Optimization: Merely running
torch.onnx.exportleaves vast amounts of performance on the table. You must perform explicit node fusion (e.g., combining attention heads) using ONNX model optimizers. - Watch your Math in FP16: Convert intermediate reductions (norm calculations, accumulators) back to FP32 to avoid numerical overflow and downstream
NaNvectors. - Keep Dynamic Axes Dynamic: Don’t force
IOBindingunless your tensor shapes are strictly fixed. The dynamic CUDA memory allocator inside ONNX Runtime handles dynamic sequence lengths efficiently without manual intervention. - Bench Your Framework Wrapper: A significant chunk of latency in production Python ML services lives outside the forward pass. Replacing native Transformers tokenizers with the Rust-backed
tokenizerslibrary and streamlining NumPy vector operations is critical to unlocking raw GPU hardware throughput.