Benchmark a subset of backend test cases against onnxruntime#

The C++ backend test registry exposed by onnx_light.onnx.backend does not only contain the small correctness cases used to validate the runtime: every operator family that supports it also registers a benchmark variant (TestMode.BENCHMARK) whose inputs are large enough (millions of elements) for a single kernel evaluation to run long enough to be timed reliably. Benchmark case names always contain "_benchmark".

This example selects a small subset of those benchmark cases – a mix of unary (Abs, Relu, Sigmoid, Sqrt, Exp, Erf) and binary (Add, Mul, Div) float32 element-wise operators – and compares the median execution time of onnx-light’s ReferenceEvaluator against onnxruntime running the very same ModelProto and input data.

from __future__ import annotations

import os
import time

import matplotlib.pyplot
import numpy
import onnxruntime

from onnx_light.onnx.backend import TestMode, collect_test_cases_by_name
from onnx_light.onnx.reference import ReferenceEvaluator

Selecting a subset of backend benchmark cases#

Every eligible operator family registers a benchmark case named test_cc_<lower op name>_benchmark (plus _float16 / _bfloat16 companions this example does not use). We only keep the float32 variant so the comparison against onnxruntime is straightforward.

BENCHMARK_OPS = ["abs", "relu", "sigmoid", "sqrt", "exp", "erf", "add", "mul", "div"]

Measurement grid#

Documentation builds use a single warm-up and repeat to keep the gallery fast; a normal run measures a more statistically stable median.

if os.environ.get("UNITTEST_GOING") == "1":
    warmup, repeat = 1, 2
else:
    warmup, repeat = 3, 7

MAX_MEASURE_DURATION = 2.0


def measure(
    function, warmup: int, repeat: int, max_duration: float = MAX_MEASURE_DURATION
) -> float:
    """Measures a callable after warm-up and returns its median time per call.

    Returns:
        The median wall-clock duration, in seconds, of at most ``repeat``
        calls to ``function`` (excluding the ``warmup`` calls). Measurement
        stops once their cumulative duration reaches ``max_duration``.
    """

    for _ in range(warmup):
        function()
    timings = []
    total_duration = 0.0
    for _ in range(repeat):
        start = time.perf_counter()
        function()
        duration = time.perf_counter() - start
        timings.append(duration)
        total_duration += duration
        if total_duration >= max_duration:
            break
    return float(numpy.median(timings))


_DTYPE_MAP = {1: numpy.float32, 10: numpy.float16}


def tensor_to_numpy(tensor) -> numpy.ndarray:
    """Converts a C++ backend-test ``Tensor`` to a :class:`numpy.ndarray`.

    Returns:
        A :class:`numpy.ndarray` view over ``tensor``'s raw bytes, reshaped to
        ``tensor.shape``.

    Raises:
        ValueError: If ``tensor.data_type`` is not one of the float types
            handled by this example (``FLOAT`` or ``FLOAT16``).
    """

    dtype = _DTYPE_MAP.get(int(tensor.data_type))
    if dtype is None:
        raise ValueError(
            f"tensor_to_numpy does not support data_type={tensor.data_type!r}; "
            f"expected one of {sorted(_DTYPE_MAP)}."
        )
    return numpy.frombuffer(tensor.raw_data(), dtype=dtype).reshape(
        tuple(int(d) for d in tensor.shape)
    )


def prepare_case(op_name: str) -> dict:
    """Prepares one backend benchmark case for both runtimes.

    Returns:
        The model, inputs, expected output, tolerance, and display metadata.
    """

    pattern = f"^test_cc_{op_name}_benchmark$"
    matches = collect_test_cases_by_name(pattern, mode=TestMode.BENCHMARK)
    if len(matches) != 1:
        raise ValueError(
            f"Expected exactly one benchmark case matching {pattern!r}, got {len(matches)}."
        )
    tc = matches[0]
    model = tc.model
    input_names = [vi.name for vi in model.graph.input]
    data_set = tc.data_sets[0]
    feeds = {name: tensor_to_numpy(tensor) for name, tensor in zip(input_names, data_set.inputs)}
    expected = tensor_to_numpy(data_set.outputs[0])
    return {
        "op_name": op_name,
        "test_name": tc.name,
        "model": model,
        "feeds": feeds,
        "expected": expected,
        "rtol": tc.rtol,
        "atol": tc.atol,
        "n_elements": int(numpy.prod(next(iter(feeds.values())).shape)),
    }


def benchmark_case(case: dict, backend: str) -> float:
    """Benchmarks one prepared case with one runtime."""
    if backend == "onnx_light":
        session = ReferenceEvaluator(case["model"])
    elif backend == "onnxruntime":
        session = onnxruntime.InferenceSession(
            case["model"].SerializeToString(), providers=["CPUExecutionProvider"]
        )
    else:
        raise ValueError(f"Unexpected backend {backend!r}.")

    def run():
        return session.run(None, case["feeds"])[0]

    numpy.testing.assert_allclose(run(), case["expected"], rtol=case["rtol"], atol=case["atol"])
    return measure(run, warmup, repeat)

Run the benchmark for every selected operator#

Every onnx-light case is completed before any ONNX Runtime session is created. Both runtimes retain their default spinning behavior, but a pool from one runtime therefore cannot remain alive while the other runtime’s cases are measured.

cases = [prepare_case(op_name) for op_name in BENCHMARK_OPS]
onnx_light_results = [benchmark_case(case, "onnx_light") for case in cases]
ort_results = [benchmark_case(case, "onnxruntime") for case in cases]
results = []
for case, onnx_light_time, ort_time in zip(cases, onnx_light_results, ort_results, strict=True):
    results.append(
        {
            "op_name": case["op_name"],
            "n_elements": case["n_elements"],
            "onnx_light_time": onnx_light_time,
            "ort_time": ort_time,
        }
    )
    print(
        f"[{case['test_name']:>28}] n={case['n_elements']:>10} | "
        f"onnx-light={onnx_light_time * 1e6:10.2f} us | "
        f"onnxruntime={ort_time * 1e6:10.2f} us | "
        f"onnx-light / onnxruntime={onnx_light_time / ort_time:5.2f}x"
    )
[       test_cc_abs_benchmark] n=   4194304 | onnx-light=    535.09 us | onnxruntime=    481.37 us | onnx-light / onnxruntime= 1.11x
[      test_cc_relu_benchmark] n=   4194304 | onnx-light=    516.32 us | onnxruntime=    400.73 us | onnx-light / onnxruntime= 1.29x
[   test_cc_sigmoid_benchmark] n=   4194304 | onnx-light=   6465.82 us | onnxruntime=    765.22 us | onnx-light / onnxruntime= 8.45x
[      test_cc_sqrt_benchmark] n=   4194304 | onnx-light=  18247.73 us | onnxruntime=    865.26 us | onnx-light / onnxruntime=21.09x
[       test_cc_exp_benchmark] n=   4194304 | onnx-light=   5367.49 us | onnxruntime=    856.58 us | onnx-light / onnxruntime= 6.27x
[       test_cc_erf_benchmark] n=   4194304 | onnx-light=  25991.10 us | onnxruntime=   1349.65 us | onnx-light / onnxruntime=19.26x
[       test_cc_add_benchmark] n=   4194304 | onnx-light=    946.26 us | onnxruntime=    801.54 us | onnx-light / onnxruntime= 1.18x
[       test_cc_mul_benchmark] n=   4194304 | onnx-light=    756.18 us | onnxruntime=    868.65 us | onnx-light / onnxruntime= 0.87x
[       test_cc_div_benchmark] n=   4194304 | onnx-light=    920.31 us | onnxruntime=    780.28 us | onnx-light / onnxruntime= 1.18x

Plot the comparison#

The left panel shows the raw median execution time for each backend, the right panel shows the speed-up of onnx-light relative to onnxruntime (values above 1.0 mean onnx-light is slower).

labels = [r["op_name"] for r in results]
onnx_light_times = numpy.array([r["onnx_light_time"] for r in results])
ort_times = numpy.array([r["ort_time"] for r in results])
x = numpy.arange(len(labels))
width = 0.35

figure, (time_axis, speedup_axis) = matplotlib.pyplot.subplots(1, 2, figsize=(12, 4.5))

time_axis.bar(x - width / 2, onnx_light_times * 1e6, width, label="onnx-light", color="#5cb85c")
time_axis.bar(x + width / 2, ort_times * 1e6, width, label="onnxruntime", color="#f4a259")
time_axis.set_xticks(x)
time_axis.set_xticklabels(labels, rotation=45, ha="right")
time_axis.set_ylabel("time (microseconds)")
time_axis.set_yscale("log")
time_axis.set_title("Backend benchmark cases: execution time")
time_axis.legend()

speedup = onnx_light_times / ort_times
speedup_axis.bar(x, speedup, color="#5cb85c")
speedup_axis.axhline(1.0, color="grey", linewidth=0.8, linestyle=":")
speedup_axis.set_xticks(x)
speedup_axis.set_xticklabels(labels, rotation=45, ha="right")
speedup_axis.set_ylabel("onnx-light / onnxruntime (lower is better)")
speedup_axis.set_title("Relative speed vs onnxruntime")

figure.tight_layout()
figure.savefig("plot_backend_benchmark_vs_onnxruntime.png")
Backend benchmark cases: execution time, Relative speed vs onnxruntime

Total running time of the script: (0 minutes 11.531 seconds)

Related examples

Benchmark Abs: onnxruntime vs onnx-light

Benchmark Abs: onnxruntime vs onnx-light

Benchmark the initialization steps: onnxruntime vs onnx-light

Benchmark the initialization steps: onnxruntime vs onnx-light

Retrieve a backend test case and display its model and data

Retrieve a backend test case and display its model and data

Gallery generated by Sphinx-Gallery

Example last updated

Date:

2026-08-25