Quantization
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.
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.
Technical references: ONNX Runtime Python API and quantization guidance.
How to use this guide
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.
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.
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
Framework vs. runtime: who owns what
| Concern | PyTorch / TF (train) | ONNX Runtime (deploy) |
|---|---|---|
| Autograd, optimizers | Yes | 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 |
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.
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)
The errors you will actually see
| What you did | What you get | Where it surfaces |
|---|---|---|
| Used an op newer than the declared opset | No Op registered for X with domain_version ... | Export time — onnx.checker |
| Declared an opset the runtime doesn't know | 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:
nn.Module (eval) ──torch.onnx.export──▶ model.onnx ──onnx.checker + ORT──▶ verified artifact
The export knobs that matter
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"}},
)
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.
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.
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.
Other paths in
| Framework | Exporter |
|---|---|
| TensorFlow / Keras | tf2onnx |
| scikit-learn | skl2onnx |
| Hugging Face models | optimum (wraps export with correct axes/opsets) |
| JAX / Flax | jax2tf then tf2onnx |
04 · Graph Optimizations
Introduction
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.
- Before you start
ONNX graph nodes, inference sessions, numerical output comparison, and basic performance terminology.
Lesson plan
- 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) |
ORT_ENABLE_BASIC | Constant folding, redundant-node removal, identity elimination |
ORT_ENABLE_EXTENDED | BASIC + node fusion: Conv+BN, Conv+ReLU, MatMul+Add, … |
ORT_ENABLE_ALL | 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)
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.
Interactive · Partitioning simulator (illustrative)
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
Provider cheat sheet
| Provider | Hardware | Typical use |
|---|---|---|
CPUExecutionProvider | any CPU | universal fallback; vectorized via MLAS |
CUDAExecutionProvider | NVIDIA GPU | default NVIDIA path |
TensorrtExecutionProvider | NVIDIA GPU | max perf; fuses subgraphs into TRT engines |
ROCMExecutionProvider | AMD GPU | ROCm stack |
DmlExecutionProvider | DirectX 12 GPU | Windows, broad vendor coverage |
OpenVINOExecutionProvider | Intel CPU/GPU/NPU | Intel-centric deployment |
CoreMLExecutionProvider | Apple silicon | macOS / iOS |
QNNExecutionProvider | Qualcomm NPU | Android / Snapdragon |
XnnpackExecutionProvider | ARM CPU | mobile CPU inference |
WebGpuExecutionProvider / WASM | browser | client-side inference |
06 · Sessions, IOBinding and Memory
Introduction
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
07 · Quantization: Smaller Numbers, Measured Trade-offs
Introduction
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.
- Compare dynamic weight-focused quantization with carefully calibrated static QDQ quantization methods.
- 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
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.
08 · Dynamic Shapes and the ORT Format
Introduction
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
})
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 .ort format: freeze the optimization
python -m onnxruntime.tools.convert_onnx_models_to_ort model.onnx # writes model.ort
python -m onnxruntime.tools.symbolic_shape_infer --input m.onnx --output m_shaped.onnx --auto_merge
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.
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 |
- Check the contract — input/output names, dtypes, shapes from
get_inputs() - Verify the export —
np.allcloseagainst 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
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 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.
The full notebook set
- What ONNX Runtime Actually Is — hand-built graph, session lifecycle
- The ONNX Format Up Close — protobuf, shape inference, IR/opset skew
- Exporting from PyTorch — dynamic axes, verification
- Graph Optimizations — levels, fusion, constant folding
- Execution Providers — selection, fallback, proof via profiler
- Sessions, IOBinding and Memory — threads, arenas, zero-copy
- Quantization — dynamic vs static QDQ, measured
- Dynamic Shapes and ORT Format — shape inference, TRT profiles, .ort
- Profiling and Debugging — traces, severity, error field guide