Nine lessons on running trained models with ONNX Runtime. Each lesson links to a Colab notebook for hands-on practice. The core exercises use a CPU; GPU work is optional.
Before you begin
You should be comfortable with Python functions and arrays. Inference means using a trained model to calculate an answer. A graph describes the operations used in that calculation. An operator is one kind of operation, such as addition or matrix multiplication.
Read the plain-language introduction, try its question, then open the notebook. Keep your measured results separate from the illustrative simulations on this page.
Read a lesson, then press its Run in Colab button. The notebooks install their dependencies and contain CPU exercises. Setup and run times vary. The interactive diagrams illustrate ideas; use notebook measurements to evaluate performance on your chosen hardware.
The one-sentence version
ONNX Runtime (ORT) is a cross-platform inference engine that executes ONNX graphs — it is a runtime, not a framework. If PyTorch and TensorFlow are languages plus compilers, then ONNX is the bytecode and ORT is the VM that runs that bytecode on CPUs, GPUs, NPUs, phones, and browsers.
Definition
ONNX (Open Neural Network Exchange) is a file format: a typed, directed acyclic graph of operators plus their weights, defined as a protobuf schema and an operator specification with versioned opsets. ONNX Runtime is an engine that loads that file, optimizes the graph, partitions it across hardware backends, and executes it — with bindings for C++, C#, Java, Python, JavaScript/WASM, Rust, and mobile.
Two things follow from that split, and they explain almost everything else in this guide:
The artifact is the contract. Training frameworks stop at the file boundary. Deployment starts there. You can train in PyTorch, export once, and execute the same bytes in a C++ service, a browser tab, and an Android app.
The runtime owns everything after the file. Optimization, kernel selection, memory planning, device placement — all deployment-time concerns, all invisible until they slow you down. This guide makes them visible.
01 · What ONNX Runtime Actually Is
Introduction
A saved model does not run itself. Given a floating-point input with shape one by four, an ONNX Runtime session validates the named tensor, executes graph nodes, and returns a named output.
Learning goal
Explain the complete runtime session lifecycle and separate inference responsibilities clearly from framework training responsibilities.
Before you start
Python functions, arrays and shapes, and a basic idea of training versus inference.
Lesson plan
Follow one request through session creation, input validation, execution, and output.
Inspect a minimal session call and identify names, shapes, and data types.
Compare what a training framework owns with what an inference runtime owns.
Trace one request. A client sends a float tensor shaped (1,4). An inference session validates its name and shape, runs the stored graph on available hardware, and returns the named output tensor. The runtime executes learned computation; it does not train the model.
A session, not a process
You do not "run" a model in ORT. You create an InferenceSession once, and call it many times. Session creation is where the runtime does its expensive work:
Parse and validate the graph (structure, opset, types)
Optimize it — fusion, constant folding, layout transforms (lesson 04)
Partition and assign kernels across Execution Providers with CPU fallback (lesson 05)
Plan memory for activations, keyed by input shape (lesson 06)
Per inference call, what remains is copying inputs, launching kernels, and copying outputs. Everything else is amortized. This is why "startup was fast, throughput is fine" and "throughput is fine, latency has overhead" are different problems with different fixes.
Example · a model with no framework
The notebook builds a linear layer with onnx.helper — literally two nodes, MatMul and Add — and executes it. No PyTorch, no autograd, no Python classes at run time. This is the smallest possible demonstration that ONNX + ORT is a complete execution path on its own.
import onnxruntime as ort
sess = ort.InferenceSession("tiny_linear.onnx", providers=["CPUExecutionProvider"])
y = sess.run(None, {"X": x})[0] # None = fetch every output
Training exists but is niche — inference is the point
Graph optimization
Compile-time for the framework
Load-time for the target hardware
Kernel selection
Fixed kernels per device backend
Chooses among CPU / CUDA / TRT / NPU per subgraph
Runtime dependencies
Heavy (Python, CUDA stack)
Small native library; Python optional
Artifact
Pickle + code
One .onnx file, versioned by opset
02 · The ONNX Format Up Close
Introduction
An ONNX file is more than a list of operations. Its model stores nodes, learned initializers, input and output contracts, plus separate IR and opset versions that can fail for different reasons.
Learning goal
Inspect an ONNX model graph and diagnose shape, operator, IR-version, and opset-version compatibility problems accurately.
Before you start
Tensors, shapes, data types, computation graphs, and basic Python object inspection.
Lesson plan
Open the model structure and locate graph nodes, initializers, inputs, and outputs.
Distinguish graph shape information from the model's IR and operator-set versions.
Match common runtime errors to the contract or version that caused them.
Read the graph as data. Each node names an operation and connects named tensors. Initializers store learned constants such as weights. Opset and IR versions describe different compatibility layers, so matching one does not guarantee matching the other.
Anatomy of a graph
An .onnx file is protobuf. A GraphProto holds:
Field
What it is
Why you care
node[]
Operators with named inputs/outputs
Fusion opportunities and fallback risk live here
initializer[]
Weights, inline as tensors
The file is self-contained; size = params × dtype bytes
input[] / output[]
Exposed tensors with types and shapes
The session's contract: names, dtypes, symbolic axes
value_info[]
Intermediate shapes
Populated by shape inference; the debugging ground truth
opset_import[]
Operator-set version
Semantics of every op; must be supported by the runtime
metadata_props[]
Key/value metadata
Carry build info, git SHA, calibration data references
Definition · IR version vs. opset
The IR version versions the protobuf container (fields available in the file). The opset version versions the semantics of operators. They move independently: a runtime can understand the container but not your opset, or vice versa. Pin both.
Real error we hit while building this guide
The onnx Python package releases faster than onnxruntime. At the time of writing, onnx stamps new models with IR version 14 while ORT supports up to 13, so a freshly built model fails to load with:
Fail: [ONNXRuntimeError] : 1 : FAIL : Unsupported model IR version: 14, max supported IR version: 13
The one-line fix — and a habit worth keeping — is model.ir_version = 8 right after helper.make_model(...). Notebook 02 demonstrates the failure and the fix side by side.
import onnx
from onnx import shape_inference
inferred = shape_inference.infer_shapes(model) # propagate shapes through the graph
json_like = onnx.printer.to_text(model) # read the protobuf as text
total_params = sum(t.dims == t.dims and int(np.prod(t.dims)) for t in model.graph.initializer)
ONNX Runtime only *guarantees* support for models stamped with official released onnx opset versions
Session creation
Fed int64 where float32 was declared
Unexpected input data type. Actual: (tensor(int64)) , expected: (tensor(float))
First run
Fed the wrong tensor name
Required inputs (['X']) are missing from input feed (['x'])
First run
03 · Exporting from PyTorch
Introduction
Export success does not prove that the deployed model is correct. A reliable path runs one fixed input through PyTorch and ONNX Runtime, checks the artifact, and compares both shapes and numeric values.
Learning goal
Export a PyTorch model and verify its structure and numerical output for fixed and dynamic inputs.
Before you start
PyTorch modules, tensors and shapes, evaluation mode, NumPy arrays, and inference outputs.
Lesson plan
Prepare a deterministic reference input and place the PyTorch model in evaluation mode.
Choose names, dynamic axes, and exporter settings while writing the ONNX artifact.
Run checker and runtime comparisons, then review alternative framework export paths.
Predict the acceptance check: for the same fixed input, PyTorch and ONNX Runtime should return the same shape and numerically close values. A successful export call only proves that a file was written.
The pipeline is three steps, and only the middle one is magic:
torch.onnx.export(
model, # .eval() — training layers behave differently
(dummy_input,), # shapes get traced through this path
"model.onnx",
input_names=["input"], # NAME YOUR TENSORS
output_names=["logits"],
opset_version=17, # pin it; verify the runtime supports it
do_constant_folding=True, # fold constant subgraphs at export time
dynamic_axes={"input": {0: "batch"}, "logits": {0: "batch"}},
)
Two exporters, one API
Modern PyTorch has a classic TorchScript exporter (dynamo=False, where dynamic_axes lives) and a torch.export-based exporter (dynamo=True, which uses dynamic_shapes instead). The notebook detects which exists and stays on the classic path — and tells you when the deprecation warning appears. Exporters will keep changing; the verification step never does.
The step everyone skips
with torch.no_grad():
ref = model(x).numpy()
got = sess.run(None, {"input": x.numpy()})[0]
assert np.allclose(ref, got, atol=1e-5) # never trust an unchecked export
What a clean export looks like
Linear → Gemm, ReLU → Relu, no aten:: remnants, no Python control flow. If you see chains of Slice/Squeeze/Unsqueeze/Concat around what should be one op, your module is fragmented — fix the module, not the runtime.
Static shapes bite
A model traced with batch 1 has batch 1 baked in. It runs in dev and fails on real traffic. The notebook demonstrates the failure, then fixes it with dynamic_axes and runs batches 1, 5, and 32 on one session.
A graph with separate convolution, normalization, and activation nodes may launch more kernels than necessary. Runtime optimization can fold constants, remove dead work, and fuse supported patterns while preserving model outputs.
Learning goal
Compare optimization levels, inspect a saved optimized graph, and verify that transformed outputs remain correct.
Identify constant folding, dead-node removal, and operator fusion in a small graph.
Change runtime optimization levels and inspect which transformations are provider-dependent.
Save the optimized model and compare outputs before trusting performance improvements.
Consider y = relu(xW+b). A runtime may fuse these operations or precompute constants while preserving the output. Verify equivalence before and after optimization. A smaller node count is useful only when the model still answers correctly.
At session creation ORT rewrites your graph. The trigger to see it: point optimized_model_filepath at a file and the session writes the post-optimization graph to disk.
Level
What it enables
ORT_DISABLE_ALL
Nothing. Graph runs as written (kernel impls still optimized)
EXTENDED + provider-tuned layout transforms (e.g. NCHWc on CPU)
so = ort.SessionOptions()
so.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
so.optimized_model_filepath = "opt_all.onnx" # audit what the runtime actually runs
sess = ort.InferenceSession("convnet.onnx", so, providers=["CPUExecutionProvider"])
Interactive · Fusion playground (illustrative)
Conv, BatchNorm, ReLU, Flatten, Gemm run as separate kernels. Frontier nodes (Input, Output) are data, not compute.
The notebook proves this on a real model: node lists at each optimization level, a constant-folding demo, and a latency comparison. Expect the fusion levels to collapse Conv + BatchNormalization + Relu — the exact resulting op names are runtime- and EP-dependent, which is why you dump the graph instead of trusting folklore.
Optimization passes change between ORT versions. A graph that fused here may split there. Version-pin the runtime, or snapshot the optimized graph and diff it in CI.
05 · Execution Providers and Graph Partitioning
Introduction
Requesting a GPU provider does not mean every node runs on the GPU. If a custom operation is unsupported, ONNX Runtime may partition the graph and silently place that node on CPU.
Learning goal
Configure execution-provider priority, inspect graph partitioning, and detect unwanted CPU fallback or costly device-transfer boundaries.
Before you start
ONNX graphs, CPU and GPU as execution devices, sessions, and basic logging.
Lesson plan
Read the provider priority list and learn how capability claims assign graph nodes.
Use the partition simulator to trace supported and unsupported operations across devices.
Inspect provider options, logs, and profiling evidence before claiming accelerator execution.
Predict a partition: if a GPU provider supports MatMul and Add but not a custom final operation, the first nodes can run on GPU while the last falls back to CPU. The session may succeed while device transfers reduce performance.
An Execution Provider is a backend that implements kernels. At session creation ORT walks the graph and asks each EP, in priority order, can you run this node? Contiguous supported runs become subgraphs; everything else falls back — ultimately to the CPU. At boundaries, tensors are copied between devices.
Fallback is silent by default. One unsupported op drags a region to CPU and you only find out by profiling. Partition count costs. Ten tiny GPU subgraphs with PCIe copies between them can lose to one clean CPU subgraph. Both are measurable — the notebook shows how.
avail = ort.get_available_providers() # what this wheel can do
providers = (["TensorrtExecutionProvider", "CUDAExecutionProvider", "CPUExecutionProvider"]
if "CUDAExecutionProvider" in avail else ["CPUExecutionProvider"])
sess = ort.InferenceSession("model.onnx", providers=providers)
print(sess.get_providers()) # the truth after partitioning
Creating a session prepares an optimized graph, selected kernels, and reusable memory structures. Reusing that session avoids repeated setup, while IOBinding can also avoid unnecessary copies between host and device memory.
Learning goal
Configure and reuse inference sessions, reason about threading and memory arenas, and apply IOBinding correctly.
Before you start
Inference sessions, tensor shapes, CPU and device memory, and Python context managers.
Lesson plan
Separate one-time session preparation from the work repeated for each request.
Examine thread settings and memory arenas with their latency and throughput trade-offs.
Bind inputs and outputs to chosen devices while preserving ownership and synchronization.
A session owns the loaded graph and execution plan. Reuse it across requests when practical. IOBinding controls where input and output buffers live; it helps only when it avoids real copies or allocations in the measured path.
A session owns three expensive things: the optimized graph, the kernels, and memory arenas. Per call, the costs are input copy-in, output allocation, and kernel launches. Two session options and one API control nearly all of it:
Knob
Default
What it does
Serving advice
intra_op_num_threads
auto
parallelism inside an op (matmul fan-out)
Set to 1 and scale with request-level parallelism
inter_op_num_threads
auto
parallelism across independent nodes
Usually leave alone
enable_cpu_mem_arena
on
reuse preallocated tensors instead of malloc per run
Leave on; turn off only to debug memory
enable_mem_pattern
on
reuse the allocation plan for repeated shapes
Leave on
IOBinding: stop copying
Normal sess.run() copies inputs into runtime buffers and allocates fresh outputs. IOBinding hands over memory directly. On CPU the win is modest; the same API is how GPU inference avoids PCIe round-trips.
io = sess.io_binding()
io.bind_cpu_input("X", x) # input stays where it is
io.bind_output("Y", "cpu") # runtime-owned output buffer
sess.run_with_iobinding(io)
y = io.copy_outputs_to_cpu()[0] # one copy, only when you ask
# fixed shapes: own the output buffer yourself
out = ort.OrtValue.ortvalue_from_numpy(np.empty((32, 128), dtype=np.float32))
io.bind_output(out) # runtime writes straight into your array
▶ Run notebook 06 in Colab06-sessions-iobinding-memory.ipynbCPU runtime · thread sweep + IOBinding benchmark
Quantization stores a range of real values with small integers. With scale 0.1 and zero point 0, value 1.2 becomes integer 12, while nearby values may round to the same level.
Learning goal
Explain integer quantization and compare dynamic and static methods using measured size, speed, and accuracy.
Before you start
Floating-point numbers, rounding, scales, model weights, activations, and basic evaluation metrics.
Lesson plan
Encode and decode one real value using scale, zero point, and rounding.
Measure artifact size, latency, and output error instead of assuming a universal gain.
Calculate one quantized value. With scale 0.1 and zero point 0, real value 1.2 maps to integer 12. A value of 1.24 also rounds to 12, so both decode near 1.2. Quantization saves space by accepting this controlled loss of precision.
Neural networks tolerate small weight perturbations, and most hardware has several times the INT8 throughput of FP32. Quantization converts fp32 models to int8 — roughly 4× smaller, often faster — for a bounded accuracy cost you get to measure.
Dynamic
Static (QDQ)
Weights
INT8
INT8
Activations
quantized on the fly
scales fixed from calibration data
Data needed
none
a few hundred samples
Typical latency win
moderate
larger — EPs optimize this path hardest
When to use
quick win, cold-start limits, transformer weights
edge / mobile / CPU serving default
from onnxruntime.quantization import quantize_dynamic, quantize_static, QuantType, QuantFormat, CalibrationDataReader
quantize_dynamic("fp32.onnx", "int8_dynamic.onnx", weight_type=QuantType.QInt8) # no data
quantize_static("fp32.onnx", "int8_qdq.onnx", calibration_data_reader=MyCalib(),
quant_format=QuantFormat.QDQ, activation_type=QuantType.QUInt8,
weight_type=QuantType.QInt8, per_channel=True) # calibrated
Interactive · Quantization error explorer
What the notebook measures
A small MLP trained on two-moons data, then three variants — fp32, dynamic INT8, static QDQ — compared on size, latency, test accuracy, and maximum relative output drift. The lesson: check task metrics and output drift; a model can hold accuracy while getting brittle at the edges of its input distribution.
A model may accept batches from 1 to 64 while keeping feature width 256. Dynamic dimensions improve flexibility, but accelerators such as TensorRT still need bounded profiles to prepare efficient engines.
Learning goal
Represent symbolic tensor dimensions, configure bounded accelerator shape profiles, and distinguish ONNX from optimized ORT artifacts.
Before you start
Tensor shapes, exported ONNX models, execution providers, and fixed versus variable dimensions.
Lesson plan
Mark selected axes dynamic while keeping each dimension's meaning and constraints clear.
Use symbolic inference and TensorRT minimum, preferred, and maximum shape profiles.
Convert to ORT format and understand its practical portability-versus-startup trade-off.
Dynamic means a dimension may change between runs; it does not mean every dimension is unconstrained. A model can accept batch sizes 1 and 8 while requiring feature width 768. Name which axes vary before configuring profiles or testing exports.
Weights are fixed at export; shapes are a deployment decision. A dimension is either fixed ([4, 512] — fastest to plan, breaks on other sizes) or symbolic (["batch", 512] — flexible, re-plans per shape).
Shape-specialized EPs turn this into a contract. TensorRT builds an engine per shape range, and the profile you give it decides which batch sizes get optimized kernels:
("TensorrtExecutionProvider", {
"trt_fp16_enable": True,
"trt_engine_cache_enable": True, # rebuilds happen once, not per process
"trt_engine_cache_path": "./trt_cache",
"trt_profile_min_shapes": "input:1x256",
"trt_profile_opt_shapes": "input:16x256", # optimize for your MEDIAN batch
"trt_profile_max_shapes": "input:64x256", # required — never leave unbounded
})
Shape out of profile
A shape outside the profile either falls back to CPU or triggers an engine rebuild. With engine caching, the rebuild happens once; without it, every new shape pays build cost. Production traffic that drifts past your max shape will show up as a latency cliff, not an error.
The conversion runs the optimizer and freezes the result into ORT's flatbuffer format — faster loads, no dead graph. But it is a build artifact, not a portable model: it is tied to the runtime build and EP set that produced it. Generate it in CI for a known target.
▶ Run notebook 08 in Colab08-dynamic-shapes-ort-format.ipynbCPU runtime · one session, batches 1→256, then .ort conversion
09 · Profiling and Debugging
Introduction
When inference is slow or incorrect, guessing from total latency is not enough. Runtime profiling records node timing and device placement, while severity-controlled logs expose loading, partitioning, and execution failures.
Learning goal
Collect an ONNX Runtime performance profile, read trace evidence, configure useful logs, and diagnose common failures.
Before you start
Inference sessions, graph nodes, files, timing, and basic JSON or browser developer tools.
Lesson plan
Enable profiling around real session runs and locate the generated trace file.
Inspect node durations and provider assignments in a Chrome-trace viewer.
Set useful log severity and map common error messages to likely causes.
Move from symptom to evidence. If latency rises, first confirm input shapes and provider placement. Then inspect the trace for expensive nodes, copies, or repeated allocations. A profile is a timeline of measured events, not an automatic diagnosis.
Three tools replace guessing: the profiler, the log severity knob, and a mental catalog of error messages.
so = ort.SessionOptions()
so.enable_profiling = True
so.profile_file_prefix = "ortprof"
sess = ort.InferenceSession(model, so, providers=["CPUExecutionProvider"])
for _ in range(5):
sess.run(None, {"X": x})
path = sess.end_profiling() # Chrome-trace JSON: every kernel, with duration
# opens in chrome://tracing, Perfetto, or in pandas
Read the profile like a budget: for a MatMul-heavy model, MatMul should dominate and elementwise ops should be noise. If a Cast chain, a Transpose pair, or a Memcpy tops the table, you have found your optimization target. On multi-EP setups, each event's provider argument tells you where the node actually ran — the tool for proving silent CPU fallback.
Severity
Meaning
0
Verbose — includes provider/partition decisions
1
Info
2
Warning
3
Error (default)
4
Fatal only
The debugging loop that works
Check the contract — input/output names, dtypes, shapes from get_inputs()
Verify the export — np.allclose against the training framework
Profile — read the top of the kernel table
Raise log severity — read the partition; confirm which EP ran what
Audit the optimized graph — dump it, open it in Netron, check the fusion you expected
Diff the versions — ORT, opset, IR, EP wheel. Version skew is the most common "impossible" bug
▶ Run notebook 09 in Colab09-profiling-debugging.ipynbCPU runtime · profile parsing + a field guide of real errors
Where It Earns Its Place
Introduction
The best deployment tool depends on the constraint, not on one universal ranking. Portable inference and CPU integer models often suit ONNX Runtime, while large NVIDIA language models or training may need other systems.
Learning goal
Choose when ONNX Runtime fits a deployment and identify cases where another tool is more appropriate.
Before you start
The earlier runtime lessons and a deployment target with known hardware and model requirements.
Lesson plan
Match portability, hardware, model type, and optimization needs to runtime strengths.
Recognize training and specialized large-language-model cases that favor other tools.
Use the notebook set and checklist to plan a measured deployment experiment.
Scenario
What to reach for
One artifact across CPU, GPU, mobile, browser
ONNX Runtime — this is the whole point
C++ / C# / Java service with no Python in production
ONNX Runtime
Edge or NPU deployment (QNN, CoreML, OpenVINO)
ONNX Runtime — the EP abstraction earns its keep
Small-to-mid models on CPU
ONNX Runtime + dynamic INT8
LLM serving on NVIDIA at scale
vLLM / TensorRT-LLM beat it for throughput; ORT GenAI is the ORT-stack alternative
Max perf, single GPU SKU, static shapes
Bare TensorRT may win slightly; the ORT TensorRT EP is close with far less work
Training
PyTorch / JAX. ORT Training exists but is not the main story
The two sentences to remember
The runtime is the deployment contract — version-pin it and verify the artifact. Every performance claim is a measurement, not a folklore rule — profile, dump the optimized graph, and check which provider actually ran your nodes.