Skip to content
AuvrynStart building

Developers

Why does my ONNX model give different results?

Small differences between an ONNX model and its source framework, or between CPU and GPU, are normal: floating-point arithmetic is not associative, and different kernels add numbers in different orders. Large differences almost always have a concrete cause — different preprocessing, a model exported in training mode, an operator whose semantics changed between opsets, reduced precision, or a different execution provider. Compare on the exact same input with tolerances, then remove one variable at a time.

Auvryn is provider-neutral execution infrastructure for AI models. It validates ONNX models, runs them as durable ExecutionJobs on infrastructure it does not own, and returns verified results with provenance and telemetry.

Which differences are expected

Two correct implementations of the same model rarely produce bit-identical floats. Fused operators, different matrix-multiplication kernels, parallel reductions and hardware with different instructions all change the order of additions. For float32 models, differences around 1e-5 to 1e-4 relative are common; they should not change a predicted class or a detection.

A difference that changes decisions, grows with depth, or appears only on some inputs is not rounding. Treat it as a bug until you can explain it.

Common causes, in the order to check them

Preprocessing
The most frequent cause. Resize method, normalization mean and std, RGB vs BGR, channel order (NCHW vs NHWC), dtype and value range must be identical. Save the preprocessed tensor and feed that same tensor to both models.
Postprocessing
Thresholds, softmax or sigmoid applied on one side only, argmax over a different axis, or non-maximum suppression with other parameters. Compare raw model outputs before comparing decisions.
Export mode
A model exported in training mode keeps dropout active and uses batch statistics in batch normalization. Call model.eval() before torch.onnx.export (with the TorchScript exporter you can also pass training=torch.onnx.TrainingMode.EVAL); editing the exported graph afterwards rarely restores the original numbers.
Tracing, control flow and dynamic shapes
Tracing records the path taken by the example input. Data-dependent branches and loops can be frozen to that path, and dimensions not declared dynamic at export are fixed to the example’s size — so a different batch size or resolution behaves differently or fails.
Operator semantics and opset
Some operators changed behaviour between opsets or are approximated by the exporter, for example resize/upsample coordinate modes and rounding. Check the exporter warnings and the opset you targeted.
Precision
float16, TF32 on recent GPUs and int8 quantization trade accuracy for speed; quantized models in particular can diverge noticeably. Compare float32 first.
Execution provider and fallback
CPU, CUDA, TensorRT and other providers use different kernels and may fuse or approximate operations. When a provider lacks a kernel, ONNX Runtime assigns that node to another provider, usually CPU, so one session can mix kernels.
Graph optimizations
ONNX Runtime rewrites and fuses the graph at session creation. Fusions change the order of operations and can shift results slightly.
Versions
Different versions of ONNX Runtime, the exporter or drivers can change kernels and defaults. Pin them and record them.
Non-deterministic kernels
Some GPU kernels use parallel reductions or atomic operations whose order varies between runs, so even the same session on the same input can differ in the last bits.

How to diagnose different results across execution targets

When the same ONNX model gives different output on another target — CPU vs GPU, ONNX Runtime vs TensorRT, a workstation vs a Jetson or another edge device, float32 vs float16 — work through the differences in this order. Each step removes one variable, so the cause is the last thing you changed.

  • Establish a baseline: one reference target (usually ONNX Runtime on CPU in float32) whose outputs you trust, ideally checked against the source framework.
  • Freeze preprocessing and inputs: save the preprocessed tensor once and feed that same file to every target.
  • Confirm artifact identity: compare the SHA-256 of the .onnx file each target actually loaded; a re-export or an optimized copy is a different model.
  • Confirm runtime versions: record the ONNX Runtime (or TensorRT) version, execution provider, driver and opset on each side.
  • Compare precision: float32 vs float16, TF32 or int8 is a change of model behaviour, not just of speed.
  • Compare graph optimizations: run both sides with the same optimization level, then vary it on one side only.
  • Define a numerical tolerance before looking at the numbers, from the task and its decision threshold.
  • Compare outputs with that tolerance, and compare the decision (class, box, score threshold) as well as the raw tensors.
  • Isolate the first divergent intermediate tensor where possible.
  • Decide: a difference inside the tolerance that does not change decisions is expected; one that changes decisions, produces inf/NaN or grows with depth is a failure to fix.

Symptoms and where to look first

These are directions to investigate, not diagnoses: the same symptom can have more than one cause.

Tiny differences (for float32, typically below about 1e-4 relative), same decisions
Floating-point order of operations: different kernels, fusions or providers. Usually expected.
Large accuracy drop on every input
Preprocessing, export in training mode, quantization or an unsupported conversion.
CPU correct, GPU or accelerator wrong
Execution provider kernels, reduced precision (float16, TF32, int8), overflow, or nodes silently placed on another provider.
PyTorch correct, ONNX wrong
The exported graph: tracing, dynamic shapes, operator semantics in the target opset, or preprocessing outside the model.
Only one runtime version fails
Operator implementation or compatibility in that version; pin versions and compare release notes.
Difference depends on input shape or batch size
Dynamic shapes fixed at export, broadcasting, padding or resize behaviour.
inf or NaN on one target only
float16 range (maximum 65504) or an unstable operation such as a sum of squares, exp or division.

Choosing a tolerance

Exact equality is the wrong requirement for floating-point inference across targets: two correct runs differ in the last bits, and a small numerical difference is not, by itself, a failure. Compare with a tolerance, and report a few numbers rather than one.

There is no universal tolerance. It depends on the model, the precision (float16 errors are orders of magnitude larger than float32 ones), the task, how close outputs sit to a decision threshold, and the runtime and hardware. Set it from what the output is used for, write it down with the result, and keep it the same when you compare targets.

import numpy as np


def compare(reference, candidate, rtol=1e-3, atol=1e-5):
    """How far `candidate` is from `reference`, and whether the decision changed."""
    ref = np.asarray(reference, dtype=np.float64)
    cand = np.asarray(candidate, dtype=np.float64)
    diff = np.abs(ref - cand)
    return {
        "finite": bool(np.isfinite(cand).all()),
        "max_abs": float(diff.max()),
        "mean_abs": float(diff.mean()),
        # Relative to the tensor's scale: element-wise relative error explodes near zero.
        "max_abs_over_scale": float(diff.max() / max(np.abs(ref).max(), 1e-12)),
        "allclose": bool(np.allclose(cand, ref, rtol=rtol, atol=atol)),
        "top1_agreement": float((ref.argmax(-1) == cand.argmax(-1)).mean()),
    }
Compare two outputs: errors, tolerance and decision
Absolute error
|reference − candidate| per element. The max absolute difference finds the worst element; the mean absolute difference shows whether the error is everywhere or concentrated.
Relative error
Absolute error divided by |reference|. Meaningful for large values, misleading near zero: after a ReLU, a 1e-7 difference on a value of 1e-9 is a relative error of 100.
Error relative to scale
Max absolute difference divided by the largest |reference| in the tensor. A stable single number for comparing layers or targets.
rtol and atol
numpy.allclose passes when |candidate − reference| ≤ atol + rtol × |reference| for every element: rtol handles large values, atol handles values near zero.
Decision agreement
Whether the downstream decision is the same: top-1 class, detections above the threshold, a score crossing a cut-off. Often the number that matters.

CPU vs another execution provider: a tested example

We ran a small image classifier (three Conv + BatchNorm + ReLU blocks, global pooling and a fully connected layer, float32, opset 17) on a batch of eight 224×224 inputs, once with the ONNX Runtime CPU provider and once on an integrated laptop GPU through the DirectML provider, both in onnxruntime-directml 1.24.4 so that only the provider changed.

The logits were not bit-identical: the largest absolute difference was 2.4e-7 (3.6e-7 of the largest logit), well inside rtol 1e-3 / atol 1e-5, and every top-1 class matched. That is the normal float32 picture; these DirectML numbers belong to this test environment (one integrated GPU, one driver, one ONNX Runtime build) and should not be generalised to other GPUs; a CUDA, TensorRT or edge provider would differ by different amounts, so measure your own.

Check that the accelerator really ran the graph: when a provider lacks a kernel, ONNX Runtime places that node on the CPU without failing. The session option session.disable_cpu_ep_fallback makes session creation fail instead, which turns a silent mix of providers into an explicit error.

import numpy as np
import onnxruntime as ort

x = np.load("input.npy")  # the same preprocessed batch for every run
cpu = ["CPUExecutionProvider"]


def run(path, providers, level=ort.GraphOptimizationLevel.ORT_ENABLE_ALL, inputs=None):
    options = ort.SessionOptions()
    options.graph_optimization_level = level
    session = ort.InferenceSession(path, options, providers=providers)
    feed = {session.get_inputs()[0].name: x if inputs is None else inputs}
    names = [o.name for o in session.get_outputs()]
    return dict(zip(names, session.run(None, feed))), session.get_providers()


accelerators = ["TensorrtExecutionProvider", "CUDAExecutionProvider", "DmlExecutionProvider",
                "CoreMLExecutionProvider", "OpenVINOExecutionProvider"]
target = next((p for p in accelerators if p in ort.get_available_providers()), None)

reference, _ = run("model.onnx", cpu)
if target is None:
    print("this onnxruntime build has no accelerator provider")
else:
    # Refuse silent fallback: session creation fails if any node would need the CPU.
    strict = ort.SessionOptions()
    strict.add_session_config_entry("session.disable_cpu_ep_fallback", "1")
    ort.InferenceSession("model.onnx", strict, providers=[target])

    candidate, used = run("model.onnx", [target, "CPUExecutionProvider"])
    print(ort.__version__, used)
    print(compare(reference["logits"], candidate["logits"]))
Same model and input on the CPU and on an accelerator provider

Graph optimization levels

ONNX Runtime rewrites the graph when a session is created: ORT_ENABLE_BASIC folds constants and removes redundant nodes, ORT_ENABLE_EXTENDED adds fusions such as Conv + BatchNormalization, and ORT_ENABLE_ALL adds layout optimizations. ORT_DISABLE_ALL runs the graph as written.

On the same classifier, on the CPU provider, every level changed the logits compared with ORT_DISABLE_ALL — none was bit-identical — but only slightly: a maximum absolute difference of 1.2e-7 for basic and extended, and 5.7e-7 for all levels (under 1e-6 of the largest logit), with identical top-1 classes. Optimizations are a plausible source of last-digit differences, rarely of decision changes; if disabling them changes a decision, look for a fusion or kernel bug.

baseline, _ = run("model.onnx", cpu, ort.GraphOptimizationLevel.ORT_DISABLE_ALL)
for name in ["ORT_ENABLE_BASIC", "ORT_ENABLE_EXTENDED", "ORT_ENABLE_ALL"]:
    out, _ = run("model.onnx", cpu, getattr(ort.GraphOptimizationLevel, name))
    identical = np.array_equal(baseline["logits"], out["logits"])
    print(name, "bit-identical:", identical, compare(baseline["logits"], out["logits"]))
Compare every optimization level with the unoptimized graph

float32 vs float16: expected variation

Converting the classifier to float16 (onnxconverter-common, keeping float32 inputs and outputs) changed the logits by up to 7.3e-4 on the CPU provider — about 1.1e-3 of the largest logit, three orders of magnitude more than the float32 differences above — and no top-1 class changed. That is expected variation for float16: compare it with a looser tolerance (here rtol 1e-2, atol 1e-3) and with the decision.

A float16 test on the CPU provider is not a test of float16 arithmetic: for most operators the CPU provider has no float16 kernel and computes in float32, so it mostly measures the effect of float16 weights. Test float16 on the provider you will deploy to.

import onnx
from onnxconverter_common import float16

fp16_model = float16.convert_float_to_float16(onnx.load("model.onnx"), keep_io_types=True)
onnx.save(fp16_model, "model_fp16.onnx")

fp32, _ = run("model.onnx", cpu)
fp16, _ = run("model_fp16.onnx", cpu)
print(compare(fp32["logits"], fp16["logits"], rtol=1e-2, atol=1e-3))
Convert to float16 and compare with the float32 model

float16 failure: same model, correct on CPU, inf on GPU

float16 tops out at 65504. A mean of squares — the core of a variance or normalization layer — overflows as soon as an activation exceeds about 256. With activations of 300, the float16 version of this two-node model returned 90000 on the CPU provider and inf on the GPU through DirectML; the float32 version returned 90000 on both (90000.01 on the GPU).

The CPU answer was right only because it computed in float32. This is the shape of many "CPU correct, GPU wrong" reports: the model is the same, and the target runs it in a precision that cannot hold an intermediate value. Fixes include keeping that subgraph in float32 (mixed precision), rescaling inputs, or using a numerically stable formulation.

from onnx import TensorProto, helper

# A mean of squares: the core of a variance or normalization layer.
graph = helper.make_graph(
    [helper.make_node("Mul", ["x", "x"], ["squared"]),
     helper.make_node("ReduceMean", ["squared"], ["mean_square"], keepdims=0)],
    "mean_square",
    [helper.make_tensor_value_info("x", TensorProto.FLOAT, [1, 1000])],
    [helper.make_tensor_value_info("mean_square", TensorProto.FLOAT, [])],
)
model = helper.make_model(graph, opset_imports=[helper.make_opsetid("", 17)])
model.ir_version = 8
onnx.save(model, "mean_square.onnx")
onnx.save(float16.convert_float_to_float16(model, keep_io_types=True), "mean_square_fp16.onnx")

activations = np.full((1, 1000), 300.0, dtype=np.float32)  # an ordinary activation value
targets = [cpu] + ([[target, "CPUExecutionProvider"]] if target else [])
for providers in targets:
    for path in ["mean_square.onnx", "mean_square_fp16.onnx"]:
        out, _ = run(path, providers, inputs=activations)
        print(providers[0], path, out["mean_square"])  # 300 squared = 90000 > 65504, the float16 max
A mean of squares in float32 and float16, on each target

Find the first layer where two runs diverge

When outputs differ beyond tolerance, compare intermediate tensors in graph order and stop at the first one that exceeds the tolerance. ONNX Runtime returns any tensor listed as a graph output, so the method is: list every intermediate tensor as an output on both sides, run both with graph optimizations off so that fused tensors still exist, and compare tensor by tensor with the same tolerance.

On the mean-of-squares model, the method stops at "squared", where float16 already holds inf. On the classifier, float32 on the CPU against float16 on the GPU first exceeded 1e-3 of scale at the very first convolution — the expected signature of a precision change, not of one broken operator. With a strict 1e-6 tolerance, float32 on the CPU against float32 on the GPU first crossed it at the third convolution, still far inside float32 noise: the tolerance decides what counts as divergent.

def expose_all(src, dst):
    """A copy of the model with every intermediate tensor also listed as an output."""
    model = onnx.shape_inference.infer_shapes(onnx.load(src))
    outputs = {o.name for o in model.graph.output}
    model.graph.output.extend(v for v in model.graph.value_info if v.name not in outputs)
    onnx.save(model, dst)
    return [name for node in model.graph.node for name in node.output]  # graph order


def first_divergence(path_a, path_b, providers_a, providers_b, inputs=None, tolerance=1e-3):
    order = expose_all(path_a, "a_all.onnx")
    expose_all(path_b, "b_all.onnx")
    off = ort.GraphOptimizationLevel.ORT_DISABLE_ALL  # keep every tensor, no fusions
    a, _ = run("a_all.onnx", providers_a, off, inputs)
    b, _ = run("b_all.onnx", providers_b, off, inputs)
    for name in order:
        if name in a and name in b:
            result = compare(a[name], b[name])
            if not result["finite"] or result["max_abs_over_scale"] > tolerance:
                return name, result
    return None, None


accel = [target, "CPUExecutionProvider"] if target else cpu
print(first_divergence("mean_square.onnx", "mean_square_fp16.onnx", cpu, accel, activations))
print(first_divergence("model.onnx", "model_fp16.onnx", cpu, accel))
print(first_divergence("model.onnx", "model.onnx", cpu, accel, tolerance=1e-6))
List every intermediate tensor as an output and compare in graph order

Limits of comparing intermediate tensors

  • Observing changes the run, so the comparison is not fully passive: listing a tensor as an output can prevent fusions that consume it and can change the precision it is stored in, and on the CPU the float16 copy of "squared" read inf even though the CPU computed it in float32 and returned 90000. Confirm a finding with an end-to-end run.
  • Optimized and fused graphs have fewer intermediate tensors; providers such as TensorRT build their own fused engines, so names may not match one to one. Compare at the boundaries that do exist, and use the provider’s own tools for engine-level layers (NVIDIA Polygraphy can mark all outputs and compare layer by layer).
  • Some providers cannot return every intermediate tensor or change its precision when exposed.
  • For large models, compare a few tensors at a time and narrow down (cut sub-models with onnx.utils.extract_model, below) instead of exposing everything at once.

Compare on the exact same input

Most investigations go wrong because the two sides never saw the same tensor. Save one preprocessed input, run both models on it, and look at the maximum absolute difference before deciding anything.

import numpy as np
import onnxruntime as ort
import torch

x = np.load("input.npy")                       # already preprocessed
model.eval()
with torch.no_grad():
    reference = model(torch.from_numpy(x)).numpy()

session = ort.InferenceSession("model.onnx", providers=["CPUExecutionProvider"])
candidate = session.run(None, {session.get_inputs()[0].name: x})[0]

print("max abs diff:", np.abs(reference - candidate).max())
np.testing.assert_allclose(reference, candidate, rtol=1e-3, atol=1e-5)
PyTorch vs ONNX Runtime on one saved input

Remove one variable at a time

  • Compare on CPU first. If CPU matches and GPU does not, the execution provider or precision is the cause.
  • Disable graph optimizations to see whether a fusion is responsible.
  • Force float32 end to end before testing float16 or int8.
  • Find the first diverging layer: expose intermediate tensors as outputs, or cut a sub-model, and compare layer by layer.
  • Pin and record the ONNX Runtime version, the execution provider, the exporter version and the opset for every comparison.
import onnx.utils
import onnxruntime as ort

options = ort.SessionOptions()
options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_DISABLE_ALL
session = ort.InferenceSession("model.onnx", options,
                               providers=["CPUExecutionProvider"])

# Keep only the part of the graph up to an intermediate tensor:
onnx.utils.extract_model("model.onnx", "head.onnx",
                         input_names=["input"], output_names=["layer3_out"])
Turn off graph optimizations; cut a sub-model to bisect

Same model, different hardware

Running one model on several targets — a cloud CPU, a GPU, an edge accelerator — multiplies these sources of difference: each target has its own runtime build, execution provider, supported precision and kernels. Without a record of which runtime, version, provider and hardware produced each output, a difference found weeks later is hard to explain.

Comparing two executions is only reliable when each one is recorded with the facts that explain it, captured at run time rather than reconstructed later:

  • the model artifact and its SHA-256, plus the opset;
  • the runtime, its version and the execution provider;
  • the execution environment, and the hardware when it is observed or reported;
  • the identity of the input (a hash of the preprocessed tensor) and the precision used;
  • the output, with its hash, and the tolerance it was compared with;
  • when the run started and finished.

Where Auvryn fits

Auvryn validates an uploaded ONNX model and test-runs it once on ONNX Runtime (CPU) in an isolated sandbox, reporting the runtime version, output shapes and first values. Every execution records the model version, the environment, runtime and hardware facts when they are known, each with its source, and every result artifact with its SHA-256. Comparing the same model across environments is something Auvryn does internally today; it is not a self-service feature, and edge environments are not offered to external users.

What this does not cover

Bit-for-bit reproducibility across hardware is generally not available for floating-point models; deterministic runtimes for specific hardware exist but are a separate topic. Large language model serving adds its own sources of non-determinism, such as batching and sampling, which this guide does not address.

The tested examples were run on 2026-10-21 with Python 3.11.9, numpy 2.4.6, onnx 1.23.2, onnxconverter-common 1.16.0, onnxruntime 1.31.0 (CPU provider) and onnxruntime-directml 1.24.4 (CPU and DirectML providers, on an integrated laptop GPU). The numbers quoted are from that model and that hardware; yours will differ. The PyTorch comparison uses the standard torch.onnx workflow and was not run here.

This guide does not cover TensorRT engines, CUDA, Jetson or other edge devices directly: the method applies to them, but the measured differences above do not.

Primary sources

Next steps

Frequently asked questions

Why are my PyTorch and ONNX outputs slightly different?

Different kernels add numbers in different orders. Differences around 1e-5 to 1e-4 relative are normal for float32; compare with np.testing.assert_allclose and tolerances rather than exact equality.

Why are ONNX Runtime results different on CPU and GPU?

The CUDA and TensorRT providers use different kernels, may fuse operators and may use reduced precision such as TF32 or float16. Check the provider, precision and graph optimizations.

My exported model gives completely different predictions. What should I check first?

Preprocessing, then whether the model was exported in eval mode. Feed the same saved, preprocessed tensor to both models before investigating anything else.

Can I make ONNX inference bit-for-bit reproducible?

On the same hardware, runtime version, provider and settings, results are usually stable; across different hardware or providers, bit-for-bit equality is generally not guaranteed. Record the runtime facts with every result.

Why does my model work on CPU but give inf, NaN or wrong results on GPU in float16?

float16 cannot hold values above 65504, and the CPU provider computes most float16 operators in float32, so an overflow can appear only on the accelerator. Keep the affected operations in float32 or rescale.

How do I find which layer causes the difference?

List intermediate tensors as graph outputs on both runs, disable graph optimizations, and compare them in graph order with the same tolerance; the first tensor above it is where to look. Confirm with an end-to-end run, because exposing tensors can change fusions and precision.

What tolerance should I use?

There is no universal value. Start around rtol 1e-3 and atol 1e-5 for float32 and looser for float16, then set it from the task: what matters is whether the downstream decision changes.

Facts reviewed 2026-10-08.