skip to content

Your ONNX export of a PyTorch model returns different numbers — how do you debug it?

level: seniorimportance: should knowfreq 45%

answer

  1. tolerance first, verdict second
  2. evaluation mode at capture time
  3. axes you did not declare are constants
  4. export halves until they disagree
  5. providers and fusions differ too

basics

~20 s

Separate a real bug from floating-point noise first by comparing with a tolerance, not equality. Then check the usual causes in order: the module was not in evaluation mode at export, shapes were baked because no dynamic axes were declared, and unsupported operators changed semantics. Bisect by exporting submodules.

solid answer

~50 s

Start by quantifying: run the same input through the eager model and through an `onnxruntime.InferenceSession` and compare with `torch.testing.assert_close` or `numpy.testing.assert_allclose` at a sane tolerance. Differences around 1e-6 are kernel and fusion noise; differences in the first decimal place are a bug. Then work down the likely causes. Was the module in evaluation mode at export? Dropout active and batch-norm using batch statistics is the single most common cause, and it looks like plausible-but-wrong output. Did the export bake shapes — no `dynamic_axes` (legacy exporter) or no `dynamic_shapes` (the `dynamo=True` exporter) means a different batch size silently reinterprets the graph. Did a control-flow branch get frozen, since the legacy exporter traces? After that, bisect: export submodules and compare layer by layer, or register intermediate outputs as extra graph outputs. Finally check environment differences — execution provider, fp16 casts, and ONNX Runtime graph optimization level, which you can lower to see whether a fusion is responsible.

code

python · 17 lines
python
import numpy as np, torch, onnxruntime as ort

model = torch.nn.Sequential(torch.nn.Linear(8, 8), torch.nn.Dropout(0.5)).eval()
x = torch.randn(2, 8)

torch.onnx.export(
    model, (x,), "m.onnx",
    input_names=["x"], output_names=["y"],
    dynamic_axes={"x": {0: "batch"}, "y": {0: "batch"}},
    opset_version=17, dynamo=False,
)

sess = ort.InferenceSession("m.onnx", providers=["CPUExecutionProvider"])
with torch.no_grad():
    expected = model(x).numpy()
got = sess.run(None, {"x": x.numpy()})[0]
np.testing.assert_allclose(expected, got, rtol=1e-4, atol=1e-5)

go deeper

for a junior

Know that an ONNX export is checked by running the same input through both PyTorch and ONNX Runtime and comparing with a tolerance, and that the model must be in evaluation mode before exporting.

for a middle

Explain why the differences arise: tracing bakes shapes and branches, unsupported operators get decomposed, and fused kernels shift the last bits. Be able to describe declaring dynamic axes and choosing an opset version.

for a senior

Demonstrate a method, not a list — quantify first, rule out training mode and baked shapes, bisect by submodule or by exposing intermediates, then isolate provider and graph-optimization effects. Say what tolerance you accept and why raising it is not a fix.

for a principal

Make parity a release gate: export in CI, assert eager-versus-runtime agreement and a task-level metric across a fixture matrix of shapes, and define who owns the second numerical stack once a runtime other than PyTorch is in the serving path.

## First: is it a bug or is it noise? Never compare exported and eager outputs with `==`. ONNX Runtime fuses operators, reassociates arithmetic and may use different kernels, all of which shift the last bits. Set an explicit tolerance — `rtol=1e-4, atol=1e-5` is a reasonable starting point for float32 — and record what tolerance the model actually needs as part of its release criteria. Absolute error at 1e-6 with a max-relative error at 1e-5 is a healthy export. A disagreement in the second significant figure, or a different argmax on a classifier, is a defect. Also measure the *right* thing: for a classifier, check top-1 agreement over a few hundred real inputs, not just tensor closeness on one synthetic batch. A model can be numerically close and still flip labels near a decision boundary — you want to know that before it ships. ## Cause 1: the module was in training mode By far the most common. If the module is not in evaluation mode when you export, dropout is active and batch normalization uses the current batch's statistics rather than its running estimates. Both get captured into the exported graph. The output is not obviously broken — it is stochastic or subtly shifted, which is worse. Put the module in evaluation mode before export and confirm it, especially in a pipeline where a training script hands a model to an export step. ## Cause 2: baked shapes Export is a tracing operation. Every dimension you do not declare dynamic is a constant in the graph. With the legacy exporter you declare them with `dynamic_axes`, naming the input and output axes that may vary; with `torch.onnx.export(..., dynamic_axes=None, dynamo=True)` the modern path runs `torch.export` first and takes `dynamic_shapes`. Skip that step and a batch of 8 through a graph exported at batch 2 will either error on a reshape or, when a reshape uses a literal, produce quietly wrong output. Any reshape or view written with hardcoded numbers is a red flag — derive sizes from the tensor. ## Cause 3: control flow and Python values The legacy exporter runs TorchScript-style tracing, so `if` on a tensor value, `.item()`, `len()`, list indices computed in Python and early returns are resolved once. The exported graph carries only the path the example input took. If the model has real conditionality, the fix is upstream: rewrite it as `torch.where`, or move to the `torch.export`-based path and express the branch as `torch.cond`. ## Cause 4: operator coverage An operator with no ONNX equivalent at the chosen `opset_version` either fails the export loudly or gets decomposed into something with slightly different edge-case behaviour — padding modes, rounding on integer division, `nan` handling in reductions, non-deterministic ops like `topk` on ties. Raising `opset_version` often fixes this because newer opsets carry closer equivalents. Read every export warning; the exporter tells you when it substituted something. ## Bisecting When the cause is not obvious, cut the model in half. Two techniques: - **Export submodules.** Export the backbone alone, then the head, and compare each against its eager counterpart. The first submodule that disagrees owns the bug. - **Expose intermediates.** Temporarily change `forward` to return intermediate activations as extra outputs and export that. Now one ONNX Runtime call gives you every checkpoint in the network, and you can find the first layer where the curves separate. ## Environment causes If eager and ONNX agree on CPU but not on GPU, you are looking at an execution-provider difference: `providers=["CUDAExecutionProvider"]` may pick different kernels or accumulate in different precision than the CPU provider, and any half-precision cast in the pipeline multiplies that. Compare on the CPU provider first to isolate. ONNX Runtime also applies graph optimizations at session creation; lowering the session's optimization level and seeing the difference disappear is strong evidence that a fusion is the culprit, and worth reporting rather than working around silently. ## Make it a gate, not an incident All of this belongs in CI. Export as a build step, run a parity test over a fixture set covering multiple shapes and both classes of input, and fail the build on regression. The reason export bugs reach production is that export is usually a one-off human step performed at the end of a project, by which point nobody remembers what the eager model returned.

  • Eager and ONNX agree at batch size 2 but disagree at batch size 16. What is the first thing you check?
    Whether the batch dimension was declared dynamic at export. Without dynamic_axes on the legacy exporter, or dynamic_shapes on the torch.export-based path, the batch size is a constant in the graph. A reshape or view built from a hardcoded number then reinterprets the larger tensor instead of erroring. Grep the model for view and reshape calls with literal sizes and rewrite them to derive from tensor shapes, then re-export.
  • The exported graph is numerically identical on CPU but drifts on the CUDA execution provider. How do you treat that?
    As an environment difference, not an export bug. Different providers select different kernels and may accumulate in different precision, and any half-precision cast widens the gap. Establish the tolerance the model tolerates on the target provider, verify a task-level metric such as top-1 agreement over real inputs rather than raw tensor closeness, and pin the provider you validated against so a silent fallback to another provider cannot change behaviour.
  • How do you find which layer introduces the divergence without exporting a dozen submodels?
    Temporarily return intermediates. Modify forward to emit the activations after each block as additional outputs and export that variant; one ONNX Runtime call then yields every checkpoint. Compare each against the eager values in order and take the first that exceeds tolerance — everything after it is downstream contamination. Once identified, revert the instrumentation and fix the offending operator or export setting.

saying these in an interview costs you the question

  • ONNX Runtime should be bit-identical to PyTorch
  • If export raised no error the graph must be correct
  • Dropout and batch norm switch off automatically during export
  • A fixed batch dimension is fine because the runtime reshapes
  • Raising the tolerance until the test passes is a fix

context